From d9ee5d6d75c9b2fbb2075ddcc8ed4d7979481351 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 16:58:47 +0100 Subject: [PATCH 01/34] Reorg phase 1: move all non-core files out of the addon root Pure move. Every .lua relocation is a rename with zero content change (git diff -M: 16 files changed, 0 insertions, 0 deletions), and the TOC load ORDER is unchanged -- only the paths on each line differ. Root now holds only Core.lua, DandersFrames.toc and DandersFrames.xml. Config/Changelog/Profile/ExportCategories/RosterEvents/API -> Core/ DesignerPresets.lua -> Designer/Presets.lua Popup.lua -> GUI/Popup.lua Options/* -> GUI/Pages/ AuraDesigner|TextDesigner|FilterRegistry settings files -> */UI/ The */UI/ split separates each module's settings UI from its runtime files, so a later TOC change can lift the whole settings layer out without touching the runtime half. Changelog.lua is CI-generated, so its move needs three references kept in step or releases silently ship a stale changelog: generate_changelog.sh OUTPUT_FILE release.yml build-skip path filter release.yml RELEASE_CHANNEL grep --- .github/workflows/release.yml | 4 +-- AuraDesigner/{ => UI}/Options.lua | 0 API.lua => Core/API.lua | 0 Changelog.lua => Core/Changelog.lua | 0 Config.lua => Core/Config.lua | 0 .../ExportCategories.lua | 0 Profile.lua => Core/Profile.lua | 0 RosterEvents.lua => Core/RosterEvents.lua | 0 DandersFrames.toc | 32 +++++++++---------- DesignerPresets.lua => Designer/Presets.lua | 0 FilterRegistry/{ => UI}/Options.lua | 0 FilterRegistry/{ => UI}/SpellPicker.lua | 0 {Options => GUI/Pages}/AutoProfiles.lua | 0 {Options => GUI/Pages}/NicknamesPage.lua | 0 {Options => GUI/Pages}/NicknamesPicker.lua | 0 {Options => GUI/Pages}/Options.lua | 0 Popup.lua => GUI/Popup.lua | 0 TextDesigner/{ => UI}/Options.lua | 0 generate_changelog.sh | 6 ++-- 19 files changed, 22 insertions(+), 20 deletions(-) rename AuraDesigner/{ => UI}/Options.lua (100%) rename API.lua => Core/API.lua (100%) rename Changelog.lua => Core/Changelog.lua (100%) rename Config.lua => Core/Config.lua (100%) rename ExportCategories.lua => Core/ExportCategories.lua (100%) rename Profile.lua => Core/Profile.lua (100%) rename RosterEvents.lua => Core/RosterEvents.lua (100%) rename DesignerPresets.lua => Designer/Presets.lua (100%) rename FilterRegistry/{ => UI}/Options.lua (100%) rename FilterRegistry/{ => UI}/SpellPicker.lua (100%) rename {Options => GUI/Pages}/AutoProfiles.lua (100%) rename {Options => GUI/Pages}/NicknamesPage.lua (100%) rename {Options => GUI/Pages}/NicknamesPicker.lua (100%) rename {Options => GUI/Pages}/Options.lua (100%) rename Popup.lua => GUI/Popup.lua (100%) rename TextDesigner/{ => UI}/Options.lua (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13f32d0e..3a8434bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: DOMINATED=true for f in $(git diff --name-only HEAD~1..HEAD 2>/dev/null); do case "$f" in - *.md|*.sh|.github/*|.gitignore|.pkgmeta|Changelog.lua) ;; + *.md|*.sh|.github/*|.gitignore|.pkgmeta|Core/Changelog.lua) ;; *) DOMINATED=false; break ;; esac done @@ -58,7 +58,7 @@ jobs: ./generate_changelog.sh VERSION=$(grep '^## Version:' DandersFrames.toc | sed 's/^## Version: //') - CHANNEL=$(grep 'DF.RELEASE_CHANNEL' Changelog.lua | sed 's/.*= "\(.*\)"/\1/') + CHANNEL=$(grep 'DF.RELEASE_CHANNEL' Core/Changelog.lua | sed 's/.*= "\(.*\)"/\1/') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "channel=$CHANNEL" >> $GITHUB_OUTPUT diff --git a/AuraDesigner/Options.lua b/AuraDesigner/UI/Options.lua similarity index 100% rename from AuraDesigner/Options.lua rename to AuraDesigner/UI/Options.lua diff --git a/API.lua b/Core/API.lua similarity index 100% rename from API.lua rename to Core/API.lua diff --git a/Changelog.lua b/Core/Changelog.lua similarity index 100% rename from Changelog.lua rename to Core/Changelog.lua diff --git a/Config.lua b/Core/Config.lua similarity index 100% rename from Config.lua rename to Core/Config.lua diff --git a/ExportCategories.lua b/Core/ExportCategories.lua similarity index 100% rename from ExportCategories.lua rename to Core/ExportCategories.lua diff --git a/Profile.lua b/Core/Profile.lua similarity index 100% rename from Profile.lua rename to Core/Profile.lua diff --git a/RosterEvents.lua b/Core/RosterEvents.lua similarity index 100% rename from RosterEvents.lua rename to Core/RosterEvents.lua diff --git a/DandersFrames.toc b/DandersFrames.toc index b88965ca..67fe72af 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -51,13 +51,13 @@ Locales\zhTW.lua DandersFrames.xml # Core -Config.lua -Changelog.lua -Profile.lua -ExportCategories.lua +Core\Config.lua +Core\Changelog.lua +Core\Profile.lua +Core\ExportCategories.lua Core.lua -DesignerPresets.lua -RosterEvents.lua +Designer\Presets.lua +Core\RosterEvents.lua # Debug Console (loads early so all modules can use DF:Debug) Debug\DebugConsole.lua @@ -76,13 +76,13 @@ GUI\GUI.lua GUI\DFFonts.lua GUI\ColorPicker.lua Features\Search.lua -Options\Options.lua -Options\AutoProfiles.lua -Options\NicknamesPage.lua -Options\NicknamesPicker.lua +GUI\Pages\Options.lua +GUI\Pages\AutoProfiles.lua +GUI\Pages\NicknamesPage.lua +GUI\Pages\NicknamesPicker.lua # Popup System (alert / confirm / input dialogs) -Popup.lua +GUI\Popup.lua # Frame System Frames\Core.lua @@ -123,7 +123,7 @@ AuraDesigner\AuraAdapter.lua AuraDesigner\Engine.lua AuraDesigner\Factory.lua AuraDesigner\SoundEngine.lua -AuraDesigner\Options.lua +AuraDesigner\UI\Options.lua # Text Designer TextDesigner\TextDesigner.lua @@ -133,13 +133,13 @@ TextDesigner\Resolver.lua TextDesigner\Render.lua TextDesigner\Migration.lua TextDesigner\Preview.lua -TextDesigner\Options.lua +TextDesigner\UI\Options.lua # Filter Registry FilterRegistry\SpellDB.lua FilterRegistry\Registry.lua -FilterRegistry\Options.lua -FilterRegistry\SpellPicker.lua +FilterRegistry\UI\Options.lua +FilterRegistry\UI\SpellPicker.lua # Aura Blacklist AuraBlacklist\Config.lua @@ -169,4 +169,4 @@ ClickCasting\UI\BindingEditor.lua ClickCasting\UI\Dialogs.lua # External API (for Wago UI Packs, etc.) -API.lua +Core\API.lua diff --git a/DesignerPresets.lua b/Designer/Presets.lua similarity index 100% rename from DesignerPresets.lua rename to Designer/Presets.lua diff --git a/FilterRegistry/Options.lua b/FilterRegistry/UI/Options.lua similarity index 100% rename from FilterRegistry/Options.lua rename to FilterRegistry/UI/Options.lua diff --git a/FilterRegistry/SpellPicker.lua b/FilterRegistry/UI/SpellPicker.lua similarity index 100% rename from FilterRegistry/SpellPicker.lua rename to FilterRegistry/UI/SpellPicker.lua diff --git a/Options/AutoProfiles.lua b/GUI/Pages/AutoProfiles.lua similarity index 100% rename from Options/AutoProfiles.lua rename to GUI/Pages/AutoProfiles.lua diff --git a/Options/NicknamesPage.lua b/GUI/Pages/NicknamesPage.lua similarity index 100% rename from Options/NicknamesPage.lua rename to GUI/Pages/NicknamesPage.lua diff --git a/Options/NicknamesPicker.lua b/GUI/Pages/NicknamesPicker.lua similarity index 100% rename from Options/NicknamesPicker.lua rename to GUI/Pages/NicknamesPicker.lua diff --git a/Options/Options.lua b/GUI/Pages/Options.lua similarity index 100% rename from Options/Options.lua rename to GUI/Pages/Options.lua diff --git a/Popup.lua b/GUI/Popup.lua similarity index 100% rename from Popup.lua rename to GUI/Popup.lua diff --git a/TextDesigner/Options.lua b/TextDesigner/UI/Options.lua similarity index 100% rename from TextDesigner/Options.lua rename to TextDesigner/UI/Options.lua diff --git a/generate_changelog.sh b/generate_changelog.sh index 87176494..ee23c16b 100755 --- a/generate_changelog.sh +++ b/generate_changelog.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash -# generate_changelog.sh — Reads CHANGELOG.md and generates Changelog.lua +# generate_changelog.sh — Reads CHANGELOG.md and generates Core/Changelog.lua # Called by GitHub Actions before packaging. set -euo pipefail CHANGELOG_FILE="CHANGELOG.md" -OUTPUT_FILE="Changelog.lua" +# Core/ since the 2026-07 folder reorg. release.yml greps this same path for the +# release channel and skips builds that only touch it — keep all three in step. +OUTPUT_FILE="Core/Changelog.lua" if [ ! -f "$CHANGELOG_FILE" ]; then echo "Error: $CHANGELOG_FILE not found." From 1d516713809e0473e60ef120bd0d1aeeaf25e887 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 17:19:18 +0100 Subject: [PATCH 02/34] GUI.lua: hold mutable module state on one table Behaviour-preserving prep, no functional change. Four file-scope locals become fields on a private GUI._state table: currentOpenDropdown read/written 9,000 lines apart overrideDebugMode read/written 6,800 lines apart navTrace _flowProbe A file-scope `local` re-declared on the far side of a file split becomes a second, independent variable. Nothing errors -- the two halves just stop sharing state, and the symptom surfaces later as a dropdown that will not close or a debug toggle that does nothing. Holding these on one table means any future split reads the same state via `local S = GUI._state`. Read-only aliases are deliberately left alone. The C_* colours, L and GUI itself are safe to re-declare because doing so yields the same table. Measured effect on splittability (docs/reorg-tools/cutpoints.py), treating the colour block as re-declarable preamble: before 0 usable cut points after 76 usable cut runs (1,176 legal lines) The split itself is not done here -- this only removes the thing that made it unsafe. --- GUI/GUI.lua | 100 +++++++++++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/GUI/GUI.lua b/GUI/GUI.lua index 621c96e2..5b428de9 100644 --- a/GUI/GUI.lua +++ b/GUI/GUI.lua @@ -3,6 +3,18 @@ local GUI = {} DF.GUI = GUI local L = DF.L +-- Mutable module state. +-- These four were file-scope locals, and two of them are read and written more +-- than 6,000 lines apart. That is harmless in one file and silently broken the +-- moment the file is split: a `local` re-declared in the second half becomes an +-- independent variable, so the two halves stop sharing state with no error. +-- Holding them on one table keeps a future split honest -- each part reads the +-- same table via `local S = GUI._state`. Read-only aliases (the C_* colours, L, +-- GUI itself) do not need this: re-declaring those yields the same value. +-- Private by convention -- nothing outside this file should touch GUI._state. +local S = {} +GUI._state = S + -- ========================================================================= -- MODERN UI CONSTANTS & STYLING (Matching Original v2.3.8) -- ========================================================================= @@ -394,14 +406,14 @@ function GUI:UpdateTabAvailability() end -- Track currently open dropdown menu (only one can be open at a time) -local currentOpenDropdown = nil +S.currentOpenDropdown = nil -- Close any currently open dropdown local function CloseOpenDropdown() - if currentOpenDropdown and currentOpenDropdown:IsShown() then - currentOpenDropdown:Hide() + if S.currentOpenDropdown and S.currentOpenDropdown:IsShown() then + S.currentOpenDropdown:Hide() end - currentOpenDropdown = nil + S.currentOpenDropdown = nil end -- Helper to get current theme color @@ -874,7 +886,6 @@ end -- mouse), then a live trace of every change in focus and in each row's plate -- alpha, stamped with the frame number. (a) and (b) show up as repeated -- transitions within a single crossing; (c) as a run of frames with nothing lit. -local navTrace function GUI.NavProbe(seconds) local container = GUI.tabContainer if not (container and container:IsVisible()) then @@ -947,14 +958,14 @@ function GUI.NavProbe(seconds) print((" %d overlapping rows, %d dead bands between rows"):format(overlaps, bands)) -- Live trace. - navTrace = navTrace or CreateFrame("Frame") - navTrace:SetScript("OnUpdate", nil) + S.navTrace = S.navTrace or CreateFrame("Frame") + S.navTrace:SetScript("OnUpdate", nil) local dur = tonumber(seconds) or 8 local elapsed, frames, events = 0, 0, 0 local lastLit, lastFocus = nil, nil print((" |cff00ff00tracing for %ds|r -- sweep the cursor across the nav list now."):format(dur)) - navTrace:SetScript("OnUpdate", function(_, dt) + S.navTrace:SetScript("OnUpdate", function(_, dt) elapsed = elapsed + dt frames = frames + 1 @@ -1002,7 +1013,7 @@ function GUI.NavProbe(seconds) end if elapsed >= dur then - navTrace:SetScript("OnUpdate", nil) + S.navTrace:SetScript("OnUpdate", nil) print((" |cff00ff00navprobe done|r -- %d frames, %d state changes%s"):format( frames, events, events > 120 and " (first 120 shown)" or "")) print(" |cff808080Read: one enter + one leave per row = clean, look elsewhere. Repeated flips inside one crossing = focus thrash. TWO LIT = a stale plate. lit=(none) with focus on a row = the handler did not fire.|r") @@ -2568,26 +2579,25 @@ end -- probe (no per-call FontString churn); measured fresh so a font-family change is -- always reflected. Returns the EXACT fractional advance (no pixel rounding) so the -- flow spaces identically to native wrapped text — see the return note below. -local _flowProbe local function FlowSpaceWidth(tmpl, sizePx) tmpl = tmpl or "DFFontHighlightSmall" - if not _flowProbe then - _flowProbe = UIParent:CreateFontString(nil, "OVERLAY") + if not S._flowProbe then + S._flowProbe = UIParent:CreateFontString(nil, "OVERLAY") end if sizePx and DF.SafeSetFont then local fontName = (DF.db and DF.db.settingsFont) or "DF Roboto SemiBold" - DF:SafeSetFont(_flowProbe, fontName, sizePx, "") + DF:SafeSetFont(S._flowProbe, fontName, sizePx, "") else - _flowProbe:SetFontObject(_G[tmpl] or _G.GameFontHighlight) + S._flowProbe:SetFontObject(_G[tmpl] or _G.GameFontHighlight) end -- Average over N spaces: each GetStringWidth is pixel-rounded, so a single-space -- "m m" - "mm" diff can inflate the space advance by ~1px (which read as too-loose -- word gaps next to the native-wrapped notes). Isolating N spaces and dividing -- shrinks that rounding error to ~1/N of a pixel, so the flow spaces like real text. local N = 12 - _flowProbe:SetText("m" .. string.rep(" m", N)); local wA = _flowProbe:GetStringWidth() - _flowProbe:SetText("m" .. string.rep("m", N)); local wB = _flowProbe:GetStringWidth() - _flowProbe:SetText("") + S._flowProbe:SetText("m" .. string.rep(" m", N)); local wA = S._flowProbe:GetStringWidth() + S._flowProbe:SetText("m" .. string.rep("m", N)); local wB = S._flowProbe:GetStringWidth() + S._flowProbe:SetText("") local sp = (wA - wB) / N if not sp or sp <= 0 then sp = 3 end -- Return the EXACT fractional advance, NOT math.floor(sp+0.5). Rounding a small @@ -4817,14 +4827,14 @@ end -- to widget containers when editing an auto profile -- Debug flag - when true, shows all reset buttons regardless of override state -local overrideDebugMode = false +S.overrideDebugMode = false -- Track all widgets with override indicators for refresh local overrideWidgets = {} -- Function to check if debug mode is active (exposed for other files) local function IsOverrideDebugMode() - return overrideDebugMode + return S.overrideDebugMode end GUI.IsOverrideDebugMode = IsOverrideDebugMode @@ -4854,12 +4864,12 @@ end -- NOT a dump, despite what the old description ("Auto layout override table -- dump") claimed — it prints no table. It forces every reset button / override -- marker visible regardless of override state, so you can see which controls --- carry the machinery at all. overrideDebugMode is live: read by +-- carry the machinery at all. S.overrideDebugMode is live: read by -- GUI.IsOverrideDebugMode and three marker call sites. DF:RegisterDebugSlash("DFOVERRIDEDEBUG", "Force-show every override marker and reset button", true, "/dfoverridedebug") SlashCmdList["DFOVERRIDEDEBUG"] = function() - overrideDebugMode = not overrideDebugMode - DF:Say("Override debug mode " .. (overrideDebugMode and "ENABLED" or "DISABLED")) + S.overrideDebugMode = not S.overrideDebugMode + DF:Say("Override debug mode " .. (S.overrideDebugMode and "ENABLED" or "DISABLED")) -- Refresh all override indicators RefreshAllOverrideIndicators() -- Also update position panel if open @@ -5044,7 +5054,7 @@ local function AddOverrideIndicators(container, lbl, dbKey, onReset, verticalOff -- Function to update override indicators container.UpdateOverrideIndicators = function(self, currentValue) -- Debug mode shows all buttons - if overrideDebugMode then + if S.overrideDebugMode then self.overrideStar:Show() self.overrideResetBtn:Show() self.overrideGlobalText:SetText("(debug)") @@ -5220,7 +5230,7 @@ local function AddOrderListOverrideIndicators(container, dbKey, onReset) -- Update function container.UpdateOverrideIndicators = function(self, currentValue) -- Debug mode - if overrideDebugMode then + if S.overrideDebugMode then self.overrideStar:Show() self.overrideResetBtn:Show() self.overrideModifiedText:SetText("Modified (debug)") @@ -7002,8 +7012,8 @@ function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, cu -- Clear tracking when hidden menuFrame:SetScript("OnHide", function() - if currentOpenDropdown == menuFrame then - currentOpenDropdown = nil + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil end if searchBox then searchBox:SetText("") @@ -7234,7 +7244,7 @@ function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, cu btn:SetScript("OnClick", function(self) if menuFrame:IsShown() then menuFrame:Hide() - currentOpenDropdown = nil + S.currentOpenDropdown = nil else -- Close any other open dropdown first CloseOpenDropdown() @@ -7269,7 +7279,7 @@ function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, cu menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 24)) end menuFrame:Show() - currentOpenDropdown = menuFrame + S.currentOpenDropdown = menuFrame if searchBox then searchBox:SetFocus() end end end) @@ -8496,8 +8506,8 @@ function GUI:CreateGrowthControl(parent, db, dbKey, callback) menuFrame:Hide() menuFrame:SetScript("OnHide", function() - if currentOpenDropdown == menuFrame then - currentOpenDropdown = nil + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil end end) @@ -8568,7 +8578,7 @@ function GUI:CreateGrowthControl(parent, db, dbKey, callback) btn:SetScript("OnClick", function(self) if menuFrame:IsShown() then menuFrame:Hide() - currentOpenDropdown = nil + S.currentOpenDropdown = nil else CloseOpenDropdown() -- Highlight current selection @@ -8582,7 +8592,7 @@ function GUI:CreateGrowthControl(parent, db, dbKey, callback) end end menuFrame:Show() - currentOpenDropdown = menuFrame + S.currentOpenDropdown = menuFrame end end) @@ -8777,8 +8787,8 @@ function GUI:CreateTextureDropdown(parent, label, dbTable, dbKey, callback, cust -- Clear tracking when hidden menuFrame:SetScript("OnHide", function() - if currentOpenDropdown == menuFrame then - currentOpenDropdown = nil + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil end searchBox:SetText("") searchBox:ClearFocus() @@ -8919,14 +8929,14 @@ function GUI:CreateTextureDropdown(parent, label, dbTable, dbKey, callback, cust btn:SetScript("OnClick", function(self) if menuFrame:IsShown() then menuFrame:Hide() - currentOpenDropdown = nil + S.currentOpenDropdown = nil else -- Close any other open dropdown first CloseOpenDropdown() -- Rebuild menu with current SharedMedia textures RebuildMenu() menuFrame:Show() - currentOpenDropdown = menuFrame + S.currentOpenDropdown = menuFrame -- Focus search box searchBox:SetFocus() end @@ -9087,8 +9097,8 @@ function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback, inherit -- Clear tracking when hidden menuFrame:SetScript("OnHide", function() - if currentOpenDropdown == menuFrame then - currentOpenDropdown = nil + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil end searchBox:SetText("") searchBox:ClearFocus() @@ -9237,14 +9247,14 @@ function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback, inherit btn:SetScript("OnClick", function(self) if menuFrame:IsShown() then menuFrame:Hide() - currentOpenDropdown = nil + S.currentOpenDropdown = nil else -- Close any other open dropdown first CloseOpenDropdown() -- Rebuild menu with current SharedMedia fonts RebuildMenu() menuFrame:Show() - currentOpenDropdown = menuFrame + S.currentOpenDropdown = menuFrame -- Focus search box searchBox:SetFocus() end @@ -9369,8 +9379,8 @@ function GUI:CreateSoundDropdown(parent, label, dbTable, dbKey, callback) end) menuFrame:SetScript("OnHide", function() - if currentOpenDropdown == menuFrame then - currentOpenDropdown = nil + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil end searchBox:SetText("") searchBox:ClearFocus() @@ -9478,12 +9488,12 @@ function GUI:CreateSoundDropdown(parent, label, dbTable, dbKey, callback) btn:SetScript("OnClick", function(self) if menuFrame:IsShown() then menuFrame:Hide() - currentOpenDropdown = nil + S.currentOpenDropdown = nil else CloseOpenDropdown() RebuildMenu() menuFrame:Show() - currentOpenDropdown = menuFrame + S.currentOpenDropdown = menuFrame searchBox:SetFocus() end end) @@ -11656,7 +11666,7 @@ function DF:CreateGUI() -- Function to update position override indicator local function UpdatePositionOverrideIndicator() -- Debug mode shows indicator - if overrideDebugMode then + if S.overrideDebugMode then positionOverrideStar:Show() return end From 405a82b3bac1034eeb17ae0763f326dd8ad5ff87 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 17:44:46 +0100 Subject: [PATCH 03/34] GUI.lua: publish wide-reaching private helpers on GUI._priv Behaviour-preserving prep, pure addition (16 lines, 0 deletions). A `local function` is invisible to any other file, so a helper called thousands of lines from its declaration pins this file together: it cannot be split while a later section still calls it. Five helpers span >1000 lines and were private: CloseOpenDropdown 412 -> 9493 PixelsPerUnit 438 -> 1467 INFO_BANNER_TONES 2475 -> 5635 AddOverrideIndicators 5016 -> 9016 AddOrderListOverrideIndicators 5205 -> 10472 Publishing them on a private GUI._priv table lets a sibling file re-declare `local X = GUI._priv.X` -- the same object, so no call site changes and behaviour is identical. Narrower helpers stay private. Read-only aliases are safe to re-declare this way; a MUTABLE local never is, which is what the earlier GUI._state commit dealt with. --- GUI/GUI.lua | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/GUI/GUI.lua b/GUI/GUI.lua index 5b428de9..953c35c5 100644 --- a/GUI/GUI.lua +++ b/GUI/GUI.lua @@ -15,6 +15,17 @@ local L = DF.L local S = {} GUI._state = S +-- Shared private helpers. +-- A `local function` is invisible to any other file, so a helper used thousands +-- of lines from its declaration pins this file together: it cannot be split +-- while a later section still calls it. Publishing the wide-reaching ones here +-- lets a sibling file re-declare `local X = GUI._priv.X` -- the same function +-- object, so no call site changes and behaviour is identical. +-- Only helpers spanning >1000 lines are listed; narrow ones stay private. +-- Private by convention -- nothing outside GUI/ should touch GUI._priv. +local P = {} +GUI._priv = P + -- ========================================================================= -- MODERN UI CONSTANTS & STYLING (Matching Original v2.3.8) -- ========================================================================= @@ -415,6 +426,7 @@ local function CloseOpenDropdown() end S.currentOpenDropdown = nil end +P.CloseOpenDropdown = CloseOpenDropdown -- Helper to get current theme color -- The theme colour for an EXPLICIT mode. Use this for any surface that belongs to @@ -441,6 +453,7 @@ local function PixelsPerUnit(frame) if not (eff and eff > 0 and physH and physH > 0) then return nil end return eff * physH / 768 end +P.PixelsPerUnit = PixelsPerUnit -- ⚠ THERE IS NO RUNTIME GEOMETRY CORRECTION, AND THERE SHOULD NOT BE ONE. -- @@ -2507,6 +2520,7 @@ local INFO_BANNER_TONES = { } -- Legacy alias: "warning" was merged into "caution" (near-duplicate golds). INFO_BANNER_TONES.warning = INFO_BANNER_TONES.caution +P.INFO_BANNER_TONES = INFO_BANNER_TONES -- Hex accent ("ffRRGGBB", for inline |c...|r escapes) matching a banner tone, so -- inline caveat text (e.g. a warning word in a subtitle) reads as the SAME @@ -5199,6 +5213,7 @@ local function AddOverrideIndicators(container, lbl, dbKey, onReset, verticalOff return container end +P.AddOverrideIndicators = AddOverrideIndicators -- Override indicators for order list controls (drag lists) -- These don't have traditional labels, so we use a compact star + reset + "Modified" badge @@ -5273,6 +5288,7 @@ local function AddOrderListOverrideIndicators(container, dbKey, onReset) -- Register for refresh tracking table.insert(overrideWidgets, container) end +P.AddOrderListOverrideIndicators = AddOrderListOverrideIndicators -- ============================================================ -- SHARED CHECK / RADIO LOOK — single source of truth From 9a2f5ec199650d2daa54e1df290890f1ed21902d Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 17:46:42 +0100 Subject: [PATCH 04/34] Split GUI.lua (13,343 lines) into five files Pure move, proven not asserted. docs/reorg-tools/split_verify.py concatenates the five parts and compares byte-for-byte against GUI/GUI.lua at the parent commit: identical apart from 66 re-declaration lines, each one listed in docs/reorg-tools/splits.manifest and printed by the tool. GUI/GUI.lua 1,963 theme, palette, pixel grid, snapping, backdrops, scrollbar -- plus GUI._state / GUI._priv GUI/Sections.lua 1,596 headers, collapsible sections, settings groups GUI/Widgets.lua 4,517 tooltips, buttons, override markers, checkboxes, edit boxes, sliders, colour picker, dropdowns GUI/Controls.lua 3,363 compound controls: expiration, growth, texture / font / sound dropdowns, order lists, gradient bar GUI/Panel.lua 1,965 DF:CreateGUI -- the settings panel shell Cut points were chosen by docs/reorg-tools/cutpoints.py, which requires two things of every boundary: no file-scope local declared before it is still referenced after it, and the cut falls between top-level statements rather than inside a function body. Checking only the first would allow a cut inside a function, producing two files that do not parse. Parts 2-5 open by re-declaring what they use as aliases -- `local GUI = DF.GUI`, `local S = GUI._state`, `local CreateElementBackdrop = GUI.CreateElementBackdrop` and so on. These are the same objects the first part created, so no call site changed anywhere in the addon. The two earlier commits are what made this legal: GUI._state removed the mutable file-scope locals (re-declaring one of those yields a SECOND variable and the halves silently stop sharing state), and GUI._priv published the private helpers reaching across thousands of lines. TOC order is load-bearing and is commented as such: GUI.lua must load first because it creates the table and the two shared sub-tables. --- DandersFrames.toc | 7 + GUI/Controls.lua | 3364 ++++++++++ GUI/GUI.lua | 15306 ++++++-------------------------------------- GUI/Panel.lua | 1965 ++++++ GUI/Sections.lua | 1597 +++++ GUI/Widgets.lua | 4518 +++++++++++++ 6 files changed, 13415 insertions(+), 13342 deletions(-) create mode 100644 GUI/Controls.lua create mode 100644 GUI/Panel.lua create mode 100644 GUI/Sections.lua create mode 100644 GUI/Widgets.lua diff --git a/DandersFrames.toc b/DandersFrames.toc index 67fe72af..9a619c94 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -72,7 +72,14 @@ Debug\AttachmentScan.lua # GUI System GUI\IconLib.lua +# GUI toolkit, split from one 13k-line file. Order is load-bearing: GUI.lua +# creates the GUI table plus GUI._state / GUI._priv, which every later part +# re-declares aliases from. GUI\GUI.lua +GUI\Sections.lua +GUI\Widgets.lua +GUI\Controls.lua +GUI\Panel.lua GUI\DFFonts.lua GUI\ColorPicker.lua Features\Search.lua diff --git a/GUI/Controls.lua b/GUI/Controls.lua new file mode 100644 index 00000000..43beb6cd --- /dev/null +++ b/GUI/Controls.lua @@ -0,0 +1,3364 @@ +-- Part 4 of the GUI toolkit, split from the original GUI.lua. +-- These re-declarations are aliases of the SAME objects the first part +-- created; they add no state. See docs/reorg-tools/splits.manifest. +local addonName, DF = ... +local GUI = DF.GUI +local L = DF.L +local S = GUI._state +local C_PANEL, C_ELEMENT, C_HOVER, C_TEXT, C_TEXT_DIM = + GUI.Colors.panel, GUI.Colors.element, GUI.Colors.hover, GUI.Colors.text, GUI.Colors.textDim +local CloseOpenDropdown = GUI._priv.CloseOpenDropdown +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local CreateElementBackdrop = GUI.CreateElementBackdrop +local StyleScrollBar = GUI.StyleScrollBar +local AddOverrideIndicators = GUI._priv.AddOverrideIndicators +local AddOrderListOverrideIndicators = GUI._priv.AddOrderListOverrideIndicators +-- ============================================================ +-- EXPIRATION CONTROLS (shared) — the 12.1-safe Expiration panel. Pairs with the +-- DF.Expiration engine (Features/Expiration.lua): the engine turns the expiryAlert* keys +-- into a secret-safe reveal, this builds the UI for them, so every consumer (AD icon/square +-- now; frame-level indicators later) renders the same flow with no hand-rolled copy. +-- +-- Flow: a master Enable toggle, then Threshold, then a Type dropdown (Border / Tint / Text / +-- Glyph — no Off; Enable owns on/off), then the Type-specific controls. +-- +-- HIDE-vs-GREY policy (the rework rule): a control that does NOT belong to the current Type is +-- HIDDEN (its row collapses via hideOn + the group's LayoutChildren + the caller's reflow); a +-- control that belongs but is momentarily inactive is GREYED via disableOn / the group's +-- disableChildrenOn (the standard grey-out). So: +-- Enable off -> everything below the toggle GREYS (stays visible to preview), like +-- CreateBorderControls' "Show Border off => grey". +-- TEXT/GLYPH -> the text box / glyph dropdown + Anchor (plus Threshold/Offsets/Size). +-- BORDER -> Match, Colour Mode, Colour (GREY under By-Time), Style, Inset, Opacity +-- (plus Threshold/Offsets/Size — Size GREYS under Match). No Anchor (centres). +-- TINT -> as BORDER but no Style (a wash has no thickness). +-- +-- Keys are the fixed expiryAlert* set on the passed dbTable (the AD per-aura proxy, or any +-- consumer's table) — every consumer stores the same keys, so no key map is needed. hideOn/ +-- disableOn predicates read dbTable directly (ignoring the arg LayoutChildren/RefreshChildStates +-- pass, which is DF.db[SelectedMode]) — the CreateBorderControls convention, and the only way +-- a per-aura proxy's state is seen. +-- +-- opts: +-- parent REQUIRED — the card/page scroll child (widgets parent to it). +-- fullUpdate value-change callback (re-render preview + live frames). +-- refreshStates relayout callback — MUST re-run hideOn (LayoutChildren), disableOn +-- (RefreshChildStates) and the sibling reflow so a mode change collapses the +-- now-irrelevant rows and slides neighbours. The mode / match / colour-mode +-- controls fire it. Called once by the caller after build for the initial state. +-- include { text, glyph, border, tint } — default all true; a consumer can drop modes. +-- anchorOptions the Anchor dropdown's option table (default: the standard 9-anchor set). +-- Returns the widget table keyed by role so a consumer can attach extra gates. +-- ============================================================ +function GUI:CreateExpirationControls(group, dbTable, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local fullUpdate = opts.fullUpdate or function() end + local refreshStates = opts.refreshStates or function() end + local L = DF.L + + -- include.match (default true): square consumers (icon/square) offer Match Icon Size + a + -- manual Size for frame modes. A RECTANGULAR consumer (bar/health) passes match=false — its + -- Tint always fills the target, so there's no Match toggle and no manual Size for it. + local includeMatch = include.match ~= false + local function enabled() return dbTable.expiryAlertEnabled and true or false end + local function mode() return dbTable.expiryAlertMode or "BORDER" end + local function isFrame() local m = mode(); return m == "BORDER" or m == "TINT" end + -- A Type / Match / Colour-Mode change alters which rows show and which grey, so it must + -- relayout + reflow AND re-render. (Value-only edits ride fullUpdate alone.) + local function onStructural() refreshStates(); fullUpdate() end + + local w = {} + + -- Master enable. When off, the whole section GREYS (group.disableChildrenOn below) — the + -- controls stay visible so the panel still previews them, matching CreateBorderControls' + -- "Show Border off => grey, don't hide". keepEnabled keeps this toggle itself clickable. + w.enable = group:AddWidget(GUI:CreateCheckbox(parent, L["Enable"], dbTable, + "expiryAlertEnabled", onStructural), 28) + w.enable.keepEnabled = true + + -- Threshold + its UNIT (right under Enable): the "show when remaining time drops below + -- N" gate every type shares. The unit is PER INDICATOR — a glyph revealing at 5 seconds + -- and a border revealing at 30% are both legitimate — and it also selects which shared + -- Colours-page ramp a by-time Border/Tint reads, because the threshold and the bands + -- are ONE formatter sampled against ONE duration property (see Features/Auras.lua). + -- Percent tops out at 100; seconds keep the original 60s ceiling. + -- ONE STORED VALUE PER UNIT (mirrors the ramps, and DF.Expiration:Threshold reads the + -- same pair): a threshold cannot be reinterpreted between units, so each keeps its own + -- and switching back finds it untouched. + -- The shared threshold row (AD's design, six other cards already use it): the slider + -- with a compact unit button sitting directly above its value box, so the number and + -- the unit read as one control. unitKeys gives it the per-unit key pair, so toggling + -- swaps which value is live rather than reinterpreting one. + -- Structural: the formatter is bind-frozen, so a unit change must Rebuild the companion + -- (DF.Expiration:StructSig folds the unit in) — refreshPage carries onStructural, and + -- the row also re-captions and re-ranges itself in place. + w.threshold = group:AddWidget(GUI:CreateExpiringThresholdRow(parent, dbTable, { + thresholdModeKey = "expiryAlertThresholdUnit", + unitKeys = { SECONDS = "expiryAlertThreshold", PERCENT = "expiryAlertThresholdPercent" }, + labels = { SECONDS = L["Alert Below (seconds)"], PERCENT = L["Alert Below (%)"] }, + ranges = { SECONDS = { min = 1, max = 60, step = 1 }, + PERCENT = { min = 1, max = 100, step = 1 } }, + -- Seeded on first use: an unset percent threshold would read 1 and hide the + -- reveal in the final 1% of the aura. + defaults = { SECONDS = 5, + PERCENT = (DF.Expiration and DF.Expiration.PERCENT_THRESHOLD_DEFAULT) or 30 }, + refreshPage = onStructural, + }), 54) + + -- Type — the reveal kind (no Off; the Enable toggle owns on/off). Border / Tint lead (the + -- primary reveals), then the Text / Glyph payloads. Consumers can drop types via include. + local modeOptions = { _order = {} } + local function addMode(key, label, on) + if on == false then return end + modeOptions[key] = label + modeOptions._order[#modeOptions._order + 1] = key + end + addMode("BORDER", L["Border"], include.border) + addMode("TINT", L["Tint"], include.tint) + addMode("TEXT", L["Custom Text"], include.text) + addMode("GLYPH", L["Glyph"], include.glyph) + w.mode = group:AddWidget(GUI:CreateDropdown(parent, L["Type"], modeOptions, + dbTable, "expiryAlertMode", onStructural), 54) + + -- TEXT: the custom alert string. + w.text = group:AddWidget(GUI:CreateEditBox(parent, L["Alert Text"], dbTable, "expiryAlertText"), 48) + w.text.hideOn = function() return mode() ~= "TEXT" end + + -- GLYPH: the glyph dropdown. Labels embed the atlas escape as a live preview via the + -- shared escape builder, so the dropdown can never drift from the live band string. + local glyphOptions = { _order = {} } + for i, gl in ipairs(DF.ExpiryAlertGlyphs) do + glyphOptions[gl.key] = DF:GetExpiryAlertGlyphEscape(gl.key, 16) .. " " .. L[gl.name] + glyphOptions._order[i] = gl.key + end + w.glyph = group:AddWidget(GUI:CreateDropdown(parent, L["Glyph"], glyphOptions, dbTable, "expiryAlertGlyph"), 54) + w.glyph.hideOn = function() return mode() ~= "GLYPH" end + + -- ── BORDER / TINT appearance: a secret-safe |T overlay revealed below the threshold, + -- tinted statically OR stepped through the same Colours-page breakpoints the duration text + -- uses. Colour Mode, Colour, Style, Opacity — every row here hides outside the frame modes. + local function hideNonFrame() return not isFrame() end + + w.colorMode = group:AddWidget(GUI:CreateDropdown(parent, L["Color Mode"], + { STATIC = L["Static"], BYTIME = L["Color by Time Remaining"] }, + dbTable, "expiryAlertBorderColorMode", onStructural), 54) -- By-Time greys the picker + w.colorMode.hideOn = hideNonFrame + + -- Cross-link to the shared Colours-page editor those By-Time breakpoints live in. Frame + -- modes only (like Color Mode itself) — a rectangular consumer with no Border/Tint (bar) + -- never reaches here, so its fixed ramp gets no link. Fixed-layout note, so size it up front. + local expLinkW = math.max(40, (group:GetWidth() or 260) - 2 * (group.padding or 10)) + w.colorsLink = GUI:CreateColorsPageLink(parent, expLinkW) + group:AddWidget(w.colorsLink, (w.colorsLink.layoutHeight or 16) + 2) + w.colorsLink.hideOn = hideNonFrame + + -- Say the blend limitation WHERE the by-time mode is chosen, not only on the + -- Colours page: the reveal's |T escapes ignore the vertex colour a curve writes, + -- so it steps even while duration text blends. + w.stepNote = group:AddWidget(GUI:CreateNote(parent, + L["The expiry border and tint always step between colors."], { width = expLinkW })) + w.stepNote.hideOn = function() + return not isFrame() or dbTable.expiryAlertBorderColorMode ~= "BYTIME" + end + + w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, + "expiryAlertBorderColor", false, fullUpdate, fullUpdate, true), 28) + w.color.hideOn = hideNonFrame + -- By-Time follows the Colours page, so the static picker is inert then — GREY (not hide) + -- so it reads as "switch to Static to use this". + w.color.disableOn = function() return dbTable.expiryAlertBorderColorMode == "BYTIME" end + + -- Style = the frame outline art (Thin/Medium/Thick — a scaled bitmap can't vary its own + -- line weight, hence discrete arts). BORDER only; a Tint is a solid wash with no thickness. + w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Style"], + { THIN = L["Thin"], MEDIUM = L["Medium"], THICK = L["Thick"], _order = { "THIN", "MEDIUM", "THICK" } }, + dbTable, "expiryAlertBorderThickness"), 54) + w.style.hideOn = function() return mode() ~= "BORDER" end + + -- Opacity: region alpha on the |T overlay (0 = invisible, 1 = full). Multiplies the art's + -- own alpha, so a Tint (50% art) tops out at a 50% wash while a frame can be fully opaque. + -- Grouped with the other appearance controls, NOT the placement run further down. + w.opacity = group:AddWidget(GUI:CreateSlider(parent, L["Opacity"], 0, 1, 0.05, dbTable, "expiryAlertBorderAlpha"), 54) + w.opacity.hideOn = hideNonFrame + + -- ── Size: Match Icon Size (auto) sits directly above Size (manual). Match is the auto/manual + -- switch and Size greys under it, so their adjacency shows the relationship. A rectangular + -- consumer (include.match = false) has no Match — its Tint always fills the target. + if includeMatch then + w.match = group:AddWidget(GUI:CreateCheckbox(parent, L["Match Icon Size"], dbTable, + "expiryAlertBorderMatchIcon", onStructural), 28) -- BORDER/TINT only + w.match.hideOn = hideNonFrame + end + + -- Size: TEXT/GLYPH use it as the font/glyph size. For a frame/tint it's the manual square + -- size — HIDDEN for a rectangular consumer (the tint auto-fills), and GREYED for a square + -- one while Match is on (auto-sized). + w.size = group:AddWidget(GUI:CreateSlider(parent, L["Size"], 6, 48, 1, dbTable, "expiryAlertSize"), 54) + w.size.hideOn = function() return not includeMatch and isFrame() end + w.size.disableOn = function() return includeMatch and isFrame() and dbTable.expiryAlertBorderMatchIcon ~= false end + + -- ── Placement: Inset (a frame/tint's fit off the icon edge), Anchor (Text/Glyph), Offsets. + w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Inset"], -10, 10, 1, dbTable, "expiryAlertBorderInset"), 54) + w.inset.hideOn = hideNonFrame + w.inset.tooltip = L["How far inside the icon edge the reveal sits. Negative values push it outward, so it rings the icon rather than sitting on it."] + + -- Anchor: Text/Glyph only — a frame/tint always centres (the engine forces CENTER), so + -- hide it in those modes rather than let a stale anchor de-centre the overlay. + w.anchor = group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], + opts.anchorOptions or { + CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], + TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], + _order = { "TOPLEFT", "TOP", "TOPRIGHT", "LEFT", "CENTER", "RIGHT", "BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT" }, + }, dbTable, "expiryAlertAnchor"), 54) + w.anchor.hideOn = function() local m = mode(); return m ~= "TEXT" and m ~= "GLYPH" end + + -- 0.5 step: the reveal rides the text engine (sub-pixel positioning we can't snap), so + -- half-steps let the user split a stubborn half-pixel offset integer steps jump over. + w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 0.5, dbTable, "expiryAlertOffsetX"), 54) + w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 0.5, dbTable, "expiryAlertOffsetY"), 54) + + -- Master gate: Enable off greys every control below the toggle (keepEnabled spares it). + -- Composes with each control's own disableOn (By-Time colour, Match-Icon size). + group.disableChildrenOn = function() return not enabled() end + + return w +end + +-- Small dim inline subheader (section divider inside a SettingsGroup), matching +-- AD's "State Overrides" / "Icon Effects" dividers. +function GUI:CreateExpiringSubheader(parent, text) + local frame = CreateFrame("Frame", nil, parent) + frame:SetHeight(18) + local label = frame:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(label, 8, "") + label:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 1) + label:SetText(text) + local c = GetThemeColor() + label:SetTextColor(c.r, c.g, c.b, 0.75) + return frame +end + +-- Threshold slider + a compact s / % SEGMENT TOGGLE sitting directly above the +-- slider's value box, so the number and the unit read as one control. The slider's +-- label/range switch with the mode, so the row rebuilds the page on toggle via +-- opts.refreshPage. Keys are parameterised (thresholdKey / thresholdModeKey) so any +-- consumer's DB schema works. +-- +-- opts.unitKeys = { SECONDS = key, PERCENT = key } switches the row to ONE STORED +-- VALUE PER UNIT instead of a single key reinterpreted between them. A threshold +-- cannot be reinterpreted (5 seconds is not 5 percent), so with this set the toggle +-- swaps which value is live and leaves the other untouched — no clamping, no reset. +-- The slider then binds through customGet/customSet and re-labels/re-ranges itself +-- from refreshContent, so the row is correct even if a consumer's refresh does not +-- rebuild it. opts.labels / opts.ranges override the slider caption and range per +-- unit; opts.modeText overrides the segment labels (default s / %); opts.resetValues +-- the single-key reset pair. Every default preserves the original behaviour. +function GUI:CreateExpiringThresholdRow(parent, dbTable, opts) + opts = opts or {} + local tKey = opts.thresholdKey + local mKey = opts.thresholdModeKey + local unitKeys = opts.unitKeys + local refresh = opts.refreshPage or function() end + local width = opts.width or 248 + local labels = opts.labels or {} + local ranges = opts.ranges or {} + local modeText = opts.modeText or {} + local function secondsNow() return mKey and dbTable[mKey] == "SECONDS" or false end + local isSeconds = secondsNow() + + local container = CreateFrame("Frame", nil, parent) + container:SetHeight(54) + container:SetWidth(width) + + -- Caption + range for a unit. Ranges default to the original pair (seconds + -- 1-60 step 1; percent 5-100 step 5). + local function unitSpec(sec) + local r = ranges[sec and "SECONDS" or "PERCENT"] + if sec then + return labels.SECONDS or L["Expiring Threshold (seconds)"], + (r and r.min) or 1, (r and r.max) or 60, (r and r.step) or 1 + end + return labels.PERCENT or L["Expiring Threshold (%)"], + (r and r.min) or 5, (r and r.max) or 100, (r and r.step) or 5 + end + + local label, minV, maxV, step = unitSpec(isSeconds) + local slider + if unitKeys then + -- Per-unit keys: resolve on EVERY access so a toggle can never write one + -- unit's number into the other's key, and seed a unit's value on first use. + local function keyNow() return secondsNow() and unitKeys.SECONDS or unitKeys.PERCENT end + local function readValue() + local k = keyNow() + local v = tonumber(dbTable and dbTable[k]) + if v == nil then + local _, dMin = unitSpec(secondsNow()) + v = (opts.defaults and opts.defaults[secondsNow() and "SECONDS" or "PERCENT"]) or dMin + if dbTable then dbTable[k] = v end + end + return v + end + slider = GUI:CreateSlider(container, label, minV, maxV, step, + nil, nil, nil, nil, nil, + readValue, function(v) if dbTable then dbTable[keyNow()] = v end end) + slider.refreshContent = function(self) + local sec = secondsNow() + local lbl, lo, hi = unitSpec(sec) + if self.label then self.label:SetText(lbl) end + self:SetRange(lo, hi) -- also re-reads the value through customGet + end + else + -- Single key reinterpreted between units: clamp it into the new range. + if isSeconds then + if tKey and dbTable[tKey] and dbTable[tKey] > maxV then dbTable[tKey] = 10 end + else + if tKey and dbTable[tKey] and dbTable[tKey] < minV then dbTable[tKey] = 30 end + end + slider = GUI:CreateSlider(container, label, minV, maxV, step, dbTable, tKey) + end + slider:SetPoint("TOPLEFT", 0, 0) + slider:SetWidth(width) + + -- Unit picker: a two-segment toggle with the units ON the buttons, boxed in one + -- track, sitting directly above the slider's value box. Terse labels (s / %) keep + -- it to the button's footprint; each segment tooltips its full name. + local modeBtn = GUI:CreateSegmentToggle(container, { + { value = "SECONDS", label = modeText.SECONDS or L["s"], tooltip = L["Seconds"] }, + { value = "PERCENT", label = modeText.PERCENT or L["%"], tooltip = L["Percent"] }, + }, dbTable, mKey, function(newVal) + local toSeconds = (newVal == "SECONDS") + -- Reset the value ONLY when one key is being reinterpreted between units. + -- With unitKeys each unit keeps its own, so switching back finds it intact. + if not unitKeys and tKey then + local r = opts.resetValues or {} + dbTable[tKey] = toSeconds and (r.SECONDS or 10) or (r.PERCENT or 30) + end + refresh() + -- Re-sync in place as well as asking for a rebuild: a consumer whose refresh + -- only re-evaluates states would otherwise leave a stale caption and range. + if slider.refreshContent then slider:refreshContent() end + end, { + segmentWidth = opts.modeSegmentWidth or 26, + fallbackValue = "PERCENT", -- matches isSeconds: an unset mode key reads as percent + tooltipLines = { L["Threshold Mode"] }, + }) + modeBtn:SetPoint("BOTTOMRIGHT", slider, "TOPRIGHT", -10, 2) + + -- Composite row: forward grey-out (disableOn) to its slider + unit toggle so the + -- whole row dims when the expiring feature is off. The row dims uniformly via + -- SetAlpha; each child blocks its own interaction (the toggle's SetEnabled dims and + -- un-mouses its segments) — deliberately NOT SetDisabled or a raw Button:SetEnabled + -- on the segments, both of which fight the shared hover wash / SetActive state. + container.SetEnabled = function(_, enabled) + container:SetAlpha(enabled and 1 or 0.4) + if slider.SetEnabled then slider:SetEnabled(enabled) end + modeBtn:SetEnabled(enabled) + end + -- Keep the unit toggle in sync on external changes (profile switch, page refresh). + container.refreshContent = function() + modeBtn:Refresh() + if slider.refreshContent then slider:refreshContent() end + end + + return container +end + +-- (GUI:CreateExpiringControls removed 2026-07-25 with the pre-12.1 Expiring system: +-- it drove the remaining-time border/tint panel, which is unreadable on the 12.1 +-- container path. The 12.1-safe panel is GUI:CreateExpirationControls above -- +-- note the near-identical name; that one is current and engine-backed.) + +-- ============================================================ +-- GROWTH DIRECTION CONTROL +-- Three linked dropdowns (Orientation, Wrap, Direction) that +-- compose into a single growth value like "LEFT_UP" +-- ============================================================ + +-- Decompose "LEFT_UP" into {orientation, wrap, direction} +local function DecomposeGrowth(growth) + local primary, secondary = strsplit("_", growth or "LEFT_UP") + if not secondary then + -- Malformed value (no underscore) — fall back to LEFT_UP + return "HORIZONTAL", "UP", "LEFT" + end + if primary == "CENTER" then + if secondary == "UP" or secondary == "DOWN" then + return "HORIZONTAL", secondary, "CENTER" + else + return "VERTICAL", secondary, "CENTER" + end + elseif primary == "LEFT" or primary == "RIGHT" then + return "HORIZONTAL", secondary, primary + else + return "VERTICAL", secondary, primary + end +end + +-- Compose {orientation, wrap, direction} back into "LEFT_UP" +local function ComposeGrowth(orientation, wrap, direction) + -- Safety: if wrap is nil, pick a sensible default for the orientation + if not wrap then + wrap = (orientation == "HORIZONTAL") and "UP" or "LEFT" + end + if direction == "CENTER" then + return "CENTER_" .. wrap + else + return direction .. "_" .. (wrap or "UP") + end +end + +-- Map values when switching orientation so the selection stays sensible +local ORIENTATION_MAP = { + UP = "LEFT", DOWN = "RIGHT", LEFT = "UP", RIGHT = "DOWN", +} + +function GUI:CreateGrowthControl(parent, db, dbKey, callback) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 155) + + -- Read current decomposed state + local curOrientation, curWrap, curDirection = DecomposeGrowth(db[dbKey] or "LEFT_UP") + + -- Option tables per orientation + -- Display text is localized; the value-keys (HORIZONTAL, UP, …) and _order + -- arrays are raw identifiers and must NOT be localized. + local ORIENT_OPTIONS = { + HORIZONTAL = L["Horizontal"], + VERTICAL = L["Vertical"], + _order = {"HORIZONTAL", "VERTICAL"}, + } + local WRAP_OPTIONS = { + HORIZONTAL = { UP = L["Up"], DOWN = L["Down"], _order = {"UP", "DOWN"} }, + VERTICAL = { LEFT = L["Left"], RIGHT = L["Right"], _order = {"LEFT", "RIGHT"} }, + } + -- "From Center" (not "Center"): the row grows OUTWARD in both directions from the + -- anchor — a behaviour, not a direction like Left/Right — so the label reads true. + -- Stored value stays CENTER (a separate locale key from the generic L["Center"] + -- used by anchor pickers elsewhere). + local DIR_OPTIONS = { + HORIZONTAL = { LEFT = L["Left"], CENTER = L["From Center"], RIGHT = L["Right"], _order = {"LEFT", "CENTER", "RIGHT"} }, + VERTICAL = { UP = L["Up"], CENTER = L["From Center"], DOWN = L["Down"], _order = {"UP", "CENTER", "DOWN"} }, + } + + -- Shared write-back: recompose and save + local function WriteBack() + db[dbKey] = ComposeGrowth(curOrientation, curWrap, curDirection) + DF:UpdateAll() + if callback then callback() end + if parent.RefreshStates then parent:RefreshStates() end + end + + -- Sub-dropdown builder (simplified version of CreateDropdown, no override indicators) + local function BuildMiniDropdown(yOffset, label, options, getValue, setValue) + local frame = CreateFrame("Frame", nil, container) + frame:SetPoint("TOPLEFT", 0, yOffset) + frame:SetPoint("TOPRIGHT", 0, yOffset) + frame:SetHeight(50) + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local btn = CreateFrame("Button", nil, frame, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, -16) + btn:SetPoint("TOPRIGHT", 0, -16) + btn:SetHeight(24) + CreateElementBackdrop(btn) + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 8, 0) + btn.Text:SetPoint("RIGHT", -20, 0) + btn.Text:SetJustifyH("LEFT") + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local arrow = btn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -8, 0) + arrow:SetSize(12, 12) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") + menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) + menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) + menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menuFrame) + menuFrame:SetClampedToScreen(true) + CreateElementBackdrop(menuFrame) + menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) + menuFrame:Hide() + + menuFrame:SetScript("OnHide", function() + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil + end + end) + + local menuButtons = {} + + -- Rebuild populates menu items from current options + frame.Rebuild = function(self, newOptions) + for _, mb in ipairs(menuButtons) do mb:Hide() end + wipe(menuButtons) + + local sorted = {} + if newOptions._order then + for _, k in ipairs(newOptions._order) do + if newOptions[k] then + sorted[#sorted + 1] = { key = k, value = newOptions[k] } + end + end + else + for k, v in pairs(newOptions) do + if k ~= "_order" then + sorted[#sorted + 1] = { key = k, value = v } + end + end + table.sort(sorted, function(a, b) return a.value < b.value end) + end + + local menuHeight = 0 + for i, opt in ipairs(sorted) do + local menuBtn = CreateFrame("Button", nil, menuFrame) + menuBtn:SetPoint("TOPLEFT", 2, -2 - (i - 1) * 22) + menuBtn:SetPoint("TOPRIGHT", -2, -2 - (i - 1) * 22) + menuBtn:SetHeight(22) + + menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + menuBtn.Text:SetPoint("LEFT", 8, 0) + menuBtn.Text:SetText(opt.value) + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") + menuBtn.Highlight:SetAllPoints() + local c = GetThemeColor() + menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) + + menuBtn:SetScript("OnClick", function() + setValue(opt.key) + WriteBack() + btn.Text:SetText(opt.value) + menuFrame:Hide() + end) + + menuButtons[#menuButtons + 1] = menuBtn + menuHeight = menuHeight + 22 + end + menuFrame:SetHeight(menuHeight + 4) + + -- Update displayed text + local curVal = getValue() + btn.Text:SetText(newOptions[curVal] or tostring(curVal) or L["Select..."]) + end + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + btn:SetScript("OnClick", function(self) + if menuFrame:IsShown() then + menuFrame:Hide() + S.currentOpenDropdown = nil + else + CloseOpenDropdown() + -- Highlight current selection + local curVal = getValue() + local curDisplay = options[curVal] + for _, mb in ipairs(menuButtons) do + if mb.Text:GetText() == curDisplay then + mb.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) + else + mb.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + menuFrame:Show() + S.currentOpenDropdown = menuFrame + end + end) + + -- Expose btn for external enable/disable, and the label so the tooltip + -- attach at the bottom of this factory has a real region to sit on — lbl + -- is local to THIS builder, so reaching for it out there is a nil global. + frame.btn = btn + frame.Label = lbl + frame:Rebuild(options) + return frame + end + + -- Build the three dropdowns (forward-declare wrap/dir so orientation callback can reference them) + local wrapDD, dirDD + local orientDD = BuildMiniDropdown(0, L["Orientation"], ORIENT_OPTIONS, + function() return curOrientation end, + function(val) + if val ~= curOrientation then + -- Map wrap and direction to the new orientation + curWrap = ORIENTATION_MAP[curWrap] or curWrap + curDirection = (curDirection == "CENTER") and "CENTER" or (ORIENTATION_MAP[curDirection] or curDirection) + curOrientation = val + -- Rebuild dependent dropdowns with new options + wrapDD:Rebuild(WRAP_OPTIONS[curOrientation]) + dirDD:Rebuild(DIR_OPTIONS[curOrientation]) + end + end + ) + + wrapDD = BuildMiniDropdown(-50, L["Wrap"], WRAP_OPTIONS[curOrientation], + function() return curWrap end, + function(val) curWrap = val end + ) + + -- "Grow" (not "Direction"): the values describe how the row GROWS from the anchor + -- (toward a side, or outward from center) — clearer than "Direction", which reads + -- oddly against the "From Center" value. + dirDD = BuildMiniDropdown(-100, L["Grow"], DIR_OPTIONS[curOrientation], + function() return curDirection end, + function(val) curDirection = val end + ) + + -- SetEnabled support for disableOn (disable the actual clickable buttons) + container.SetEnabled = function(self, enabled) + local alpha = enabled and 1.0 or 0.4 + self:SetAlpha(alpha) + orientDD.btn:SetEnabled(enabled) + wrapDD.btn:SetEnabled(enabled) + dirDD.btn:SetEnabled(enabled) + end + + -- Refresh from db (e.g., after profile switch) + container.refreshContent = function(self) + curOrientation, curWrap, curDirection = DecomposeGrowth(db[dbKey] or "LEFT_UP") + orientDD:Rebuild(ORIENT_OPTIONS) + wrapDD:Rebuild(WRAP_OPTIONS[curOrientation]) + dirDD:Rebuild(DIR_OPTIONS[curOrientation]) + end + + -- Tooltip: shared attach. This widget has no label of its own — it is three + -- stacked mini dropdowns (Orientation / Wrap / Grow), each built by the local + -- BuildMiniDropdown rather than CreateDropdown, so none of them carries an + -- attach either. The top row's label stands in for the group. + -- + -- ⚠ NOT `lbl`: that name IS in this file, but it is local to + -- BuildMiniDropdown, so reading it here is a nil global — legal Lua, parses + -- clean, and would have silently left this control with no tooltip at all. + GUI:AttachTooltip(container, L["Growth Direction"], orientDD.Label) + + return container +end + +-- ============================================================ +-- TEXTURE DROPDOWN WITH PREVIEW +-- ============================================================ + +function GUI:CreateTextureDropdown(parent, label, dbTable, dbKey, callback, customOptions) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 50) + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) + end + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, -16) + btn:SetPoint("TOPRIGHT", 0, -16) + btn:SetHeight(24) + CreateElementBackdrop(btn) + + -- Texture preview on button + btn.Preview = btn:CreateTexture(nil, "ARTWORK") + btn.Preview:SetPoint("LEFT", 4, 0) + btn.Preview:SetSize(80, 16) + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 90, 0) + btn.Text:SetPoint("RIGHT", -20, 0) + btn.Text:SetJustifyH("LEFT") + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Arrow indicator + local arrow = btn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -8, 0) + arrow:SetSize(12, 12) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local function UpdateText() + if dbTable and dbKey then + local val = dbTable[dbKey] + local displayName + if customOptions then + -- Use custom options lookup + displayName = customOptions[val] + else + -- Use robust SharedMedia lookup + displayName = DF:GetTextureNameFromPath(val) + end + btn.Text:SetText(displayName or L["Select..."]) + -- Handle "Solid" special case (not a valid texture path) + if val == "Solid" then + btn.Preview:SetColorTexture(0.3, 0.3, 0.3, 1) + else + btn.Preview:SetTexture(val) + btn.Preview:SetVertexColor(0.3, 0.7, 0.3) -- Green tint for preview + end + end + end + + -- Menu frame with scroll + local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") + menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) + menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menuFrame) + menuFrame:SetClampedToScreen(true) + CreateElementBackdrop(menuFrame) + menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) + menuFrame:Hide() + + -- Search box at top of menu + local SEARCH_HEIGHT = 26 + local searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") + searchBox:SetPoint("TOPLEFT", 4, -4) + searchBox:SetPoint("TOPRIGHT", -4, -4) + searchBox:SetHeight(22) + searchBox:SetAutoFocus(false) + searchBox:SetFontObject(DFFontHighlightSmall) + searchBox:SetTextInsets(24, 8, 0, 0) + CreateElementBackdrop(searchBox) + searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) + + -- Search icon + local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") + searchIcon:SetPoint("LEFT", 6, 0) + searchIcon:SetSize(12, 12) + searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") + searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Placeholder text + local placeholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + placeholder:SetPoint("LEFT", 24, 0) + placeholder:SetText(L["Search textures..."]) + placeholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + + searchBox:SetScript("OnEditFocusGained", function() placeholder:Hide() end) + searchBox:SetScript("OnEditFocusLost", function() + if searchBox:GetText() == "" then placeholder:Show() end + end) + + -- Clear tracking when hidden + menuFrame:SetScript("OnHide", function() + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil + end + searchBox:SetText("") + searchBox:ClearFocus() + placeholder:Show() + end) + + -- Scroll frame - positioned below search box + local scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) + scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) + + local scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetWidth(234) -- Match button width for texture dropdown + scrollFrame:SetScrollChild(scrollChild) + + StyleScrollBar(scrollFrame) + + local menuButtons = {} + local ITEM_HEIGHT = 28 + local MAX_VISIBLE = 8 + + -- Function to rebuild menu with current textures + local function RebuildMenu(filterText) + -- Clear old buttons + for _, menuBtn in ipairs(menuButtons) do + menuBtn:Hide() + menuBtn:SetParent(nil) + end + wipe(menuButtons) + + -- Get fresh texture list (use custom options if provided) + local options = customOptions or DF:GetTextureList() + local sortedOptions = {} + + -- Apply filter if provided + filterText = filterText and filterText:lower() or "" + + for k, v in pairs(options) do + if filterText == "" or v:lower():find(filterText, 1, true) then + table.insert(sortedOptions, {key = k, value = v}) + end + end + table.sort(sortedOptions, function(a, b) return a.value < b.value end) + + -- Resize menu and scroll child + local menuHeight = math.min(#sortedOptions, MAX_VISIBLE) * ITEM_HEIGHT + SEARCH_HEIGHT + 8 + menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) + menuFrame:SetHeight(menuHeight) + scrollChild:SetHeight(#sortedOptions * ITEM_HEIGHT) + + -- Hide scrollbar if not needed + if scrollBar then + if #sortedOptions <= MAX_VISIBLE then + scrollBar:Hide() + else + scrollBar:Show() + end + end + + -- Create new buttons + for i, opt in ipairs(sortedOptions) do + local menuBtn = CreateFrame("Button", nil, scrollChild) + menuBtn:SetSize(234, ITEM_HEIGHT) + menuBtn:SetPoint("TOPLEFT", 0, -(i - 1) * ITEM_HEIGHT) + + -- Texture preview + menuBtn.Preview = menuBtn:CreateTexture(nil, "ARTWORK") + menuBtn.Preview:SetPoint("LEFT", 4, 0) + menuBtn.Preview:SetSize(80, 18) + -- Handle "Solid" special case + if opt.key == "Solid" then + menuBtn.Preview:SetColorTexture(0.3, 0.3, 0.3, 1) + else + menuBtn.Preview:SetTexture(opt.key) + menuBtn.Preview:SetVertexColor(0.3, 0.7, 0.3) -- Green tint for preview + end + + menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + menuBtn.Text:SetPoint("LEFT", 90, 0) + menuBtn.Text:SetText(opt.value) + + -- Highlight selected item + if dbTable[dbKey] == opt.key then + menuBtn.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + + menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") + menuBtn.Highlight:SetAllPoints() + local c = GetThemeColor() + menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) + + menuBtn:SetScript("OnClick", function() + -- Runtime override protection + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, opt.key) then + UpdateText() + menuFrame:Hide() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(opt.key) end + return + end + dbTable[dbKey] = opt.key + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, opt.key) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(opt.key) + end + UpdateText() + menuFrame:Hide() + DF:UpdateAll() + if callback then callback() end + end) + + table.insert(menuButtons, menuBtn) + end + end + + -- Search box text changed handler + searchBox:SetScript("OnTextChanged", function(self) + RebuildMenu(self:GetText()) + end) + + -- Allow escape to close + searchBox:SetScript("OnEscapePressed", function() + menuFrame:Hide() + end) + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + btn:SetScript("OnClick", function(self) + if menuFrame:IsShown() then + menuFrame:Hide() + S.currentOpenDropdown = nil + else + -- Close any other open dropdown first + CloseOpenDropdown() + -- Rebuild menu with current SharedMedia textures + RebuildMenu() + menuFrame:Show() + S.currentOpenDropdown = menuFrame + -- Focus search box + searchBox:SetFocus() + end + end) + + btn:SetScript("OnShow", UpdateText) + UpdateText() + + -- Refresh override indicators on show + container:SetScript("OnShow", function() + UpdateText() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so its preview/value (texture swatch, font preview, + -- selected text) greys with the label rather than staying full-bright. + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- SEARCH: Register this setting (use current texture list) + if DF.Search and dbKey and type(dbKey) == "string" then + local currentOptions = customOptions or DF:GetTextureList() + container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, currentOptions, nil, callback) + end + + -- Tooltip: shared attach on the LABEL only. Hand-rolled preview dropdown, so + -- it never picked up CreateDropdown's tooltip support. + GUI:AttachTooltip(container, label or L["Texture"], lbl) + + return container +end + +-- ============================================================ +-- FONT DROPDOWN WITH PREVIEW +-- ============================================================ + +-- inheritKey (optional): when dbTable[dbKey] is nil (no per-element override), +-- the dropdown DISPLAYS dbTable[inheritKey] instead so it shows the inherited +-- (e.g. global) font. Selecting a font still writes dbKey (the override). +function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback, inheritKey) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 50) + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) + end + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, -16) + btn:SetPoint("TOPRIGHT", 0, -16) + btn:SetHeight(24) + CreateElementBackdrop(btn) + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 8, 0) + btn.Text:SetPoint("RIGHT", -20, 0) + btn.Text:SetJustifyH("LEFT") + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Arrow indicator + local arrow = btn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -8, 0) + arrow:SetSize(12, 12) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local function UpdateText() + if dbTable and dbKey then + local val = dbTable[dbKey] or (inheritKey and dbTable[inheritKey]) + -- Get font display name (handles both names and legacy paths) + local displayName = DF:GetFontNameFromPath(val) + btn.Text:SetText(displayName or L["Select..."]) + -- Try to set the button text to the selected font for preview + local fontPath = DF:GetFontPath(val) + if fontPath then + local success = pcall(function() + btn.Text:SetFont(fontPath, 12, "") + end) + if not success then + btn.Text:SetFontObject(DFFontHighlightSmall) + end + end + end + end + + -- Menu frame with scroll + local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") + menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) + menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menuFrame) + menuFrame:SetClampedToScreen(true) + CreateElementBackdrop(menuFrame) + menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) + menuFrame:Hide() + + -- Search box at top of menu + local SEARCH_HEIGHT = 26 + local searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") + searchBox:SetPoint("TOPLEFT", 4, -4) + searchBox:SetPoint("TOPRIGHT", -4, -4) + searchBox:SetHeight(22) + searchBox:SetAutoFocus(false) + searchBox:SetFontObject(DFFontHighlightSmall) + searchBox:SetTextInsets(24, 8, 0, 0) + CreateElementBackdrop(searchBox) + searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) + + -- Search icon + local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") + searchIcon:SetPoint("LEFT", 6, 0) + searchIcon:SetSize(12, 12) + searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") + searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Placeholder text + local placeholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + placeholder:SetPoint("LEFT", 24, 0) + placeholder:SetText(L["Search fonts..."]) + placeholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + + searchBox:SetScript("OnEditFocusGained", function() placeholder:Hide() end) + searchBox:SetScript("OnEditFocusLost", function() + if searchBox:GetText() == "" then placeholder:Show() end + end) + + -- Clear tracking when hidden + menuFrame:SetScript("OnHide", function() + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil + end + searchBox:SetText("") + searchBox:ClearFocus() + placeholder:Show() + end) + + -- Scroll frame - positioned below search box + local scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) + scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) + + local scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetWidth(234) -- Match button width for font dropdown + scrollFrame:SetScrollChild(scrollChild) + + StyleScrollBar(scrollFrame) + + local menuButtons = {} + local ITEM_HEIGHT = 24 + local MAX_VISIBLE = 10 + + -- Function to rebuild menu with current fonts + local function RebuildMenu(filterText) + -- Clear old buttons + for _, menuBtn in ipairs(menuButtons) do + menuBtn:Hide() + menuBtn:SetParent(nil) + end + wipe(menuButtons) + + -- Get fresh font list + local options = DF:GetFontList() + local sortedOptions = {} + + -- Apply filter if provided + filterText = filterText and filterText:lower() or "" + + for k, v in pairs(options) do + if filterText == "" or v:lower():find(filterText, 1, true) then + table.insert(sortedOptions, {key = k, value = v}) + end + end + table.sort(sortedOptions, function(a, b) return a.value < b.value end) + + -- Resize menu and scroll child + local menuHeight = math.min(#sortedOptions, MAX_VISIBLE) * ITEM_HEIGHT + SEARCH_HEIGHT + 8 + menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) + menuFrame:SetHeight(menuHeight) + scrollChild:SetHeight(#sortedOptions * ITEM_HEIGHT) + + -- Hide scrollbar if not needed + if scrollBar then + if #sortedOptions <= MAX_VISIBLE then + scrollBar:Hide() + else + scrollBar:Show() + end + end + + -- Create new buttons + for i, opt in ipairs(sortedOptions) do + local menuBtn = CreateFrame("Button", nil, scrollChild) + menuBtn:SetSize(234, ITEM_HEIGHT) + menuBtn:SetPoint("TOPLEFT", 0, -(i - 1) * ITEM_HEIGHT) + + menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY") + menuBtn.Text:SetPoint("LEFT", 8, 0) + menuBtn.Text:SetPoint("RIGHT", -8, 0) + menuBtn.Text:SetJustifyH("LEFT") + + -- Set default font first, then try to use the actual font for preview + menuBtn.Text:SetFontObject(DFFontHighlightSmall) + + -- Try to preview in the actual font + local LSM = DF.GetLSM and DF.GetLSM() + if LSM then + local fontPath = LSM:Fetch("font", opt.key) + if fontPath then + pcall(function() + menuBtn.Text:SetFont(fontPath, 12, "") + end) + end + end + + menuBtn.Text:SetText(opt.value) + + -- Highlight selected item (compare with stored font name) + local currentValue = dbTable[dbKey] + local currentName = DF:GetFontNameFromPath(currentValue) + if currentName == opt.key or currentValue == opt.key then + menuBtn.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + + menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") + menuBtn.Highlight:SetAllPoints() + local c = GetThemeColor() + menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) + + menuBtn:SetScript("OnClick", function() + -- Runtime override protection + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, opt.key) then + UpdateText() + menuFrame:Hide() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(opt.key) end + return + end + -- Store font NAME in database (not path) + dbTable[dbKey] = opt.key + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, opt.key) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(opt.key) + end + UpdateText() + menuFrame:Hide() + DF:UpdateAll() + if callback then callback() end + end) + + table.insert(menuButtons, menuBtn) + end + end + + -- Search box text changed handler + searchBox:SetScript("OnTextChanged", function(self) + RebuildMenu(self:GetText()) + end) + + -- Allow escape to close + searchBox:SetScript("OnEscapePressed", function() + menuFrame:Hide() + end) + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + btn:SetScript("OnClick", function(self) + if menuFrame:IsShown() then + menuFrame:Hide() + S.currentOpenDropdown = nil + else + -- Close any other open dropdown first + CloseOpenDropdown() + -- Rebuild menu with current SharedMedia fonts + RebuildMenu() + menuFrame:Show() + S.currentOpenDropdown = menuFrame + -- Focus search box + searchBox:SetFocus() + end + end) + + btn:SetScript("OnShow", UpdateText) + UpdateText() + + -- Refresh override indicators on show + container:SetScript("OnShow", function() + UpdateText() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so its preview/value (texture swatch, font preview, + -- selected text) greys with the label rather than staying full-bright. + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- SEARCH: Register this setting (use current font list) + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, DF:GetFontList(), nil, callback) + end + + -- Tooltip: shared attach on the LABEL only. Hand-rolled preview dropdown, so + -- it never picked up CreateDropdown's tooltip support. + GUI:AttachTooltip(container, label or L["Font"], lbl) + + return container +end + +-- ============================================================ +-- SOUND DROPDOWN (Searchable, scrollable — mirrors font dropdown) +-- ============================================================ + +function GUI:CreateSoundDropdown(parent, label, dbTable, dbKey, callback) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 50) + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Button + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, -16) + btn:SetPoint("TOPRIGHT", 0, -16) + btn:SetHeight(24) + CreateElementBackdrop(btn) + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 8, 0) + btn.Text:SetPoint("RIGHT", -20, 0) + btn.Text:SetJustifyH("LEFT") + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Arrow indicator + local arrow = btn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -8, 0) + arrow:SetSize(12, 12) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local function UpdateText() + if dbTable and dbKey then + local val = dbTable[dbKey] + btn.Text:SetText(val or L["Select..."]) + end + end + + -- Menu frame with scroll + local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") + menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) + menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menuFrame) + menuFrame:SetClampedToScreen(true) + CreateElementBackdrop(menuFrame) + menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) + menuFrame:Hide() + + -- Search box at top of menu + local SEARCH_HEIGHT = 26 + local searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") + searchBox:SetPoint("TOPLEFT", 4, -4) + searchBox:SetPoint("TOPRIGHT", -4, -4) + searchBox:SetHeight(22) + searchBox:SetAutoFocus(false) + searchBox:SetFontObject(DFFontHighlightSmall) + searchBox:SetTextInsets(24, 8, 0, 0) + CreateElementBackdrop(searchBox) + searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) + + -- Search icon + local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") + searchIcon:SetPoint("LEFT", 6, 0) + searchIcon:SetSize(12, 12) + searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") + searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Placeholder text + local searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + searchPlaceholder:SetPoint("LEFT", 24, 0) + searchPlaceholder:SetText(L["Search sounds..."]) + searchPlaceholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + + searchBox:SetScript("OnEditFocusGained", function() searchPlaceholder:Hide() end) + searchBox:SetScript("OnEditFocusLost", function() + if searchBox:GetText() == "" then searchPlaceholder:Show() end + end) + + menuFrame:SetScript("OnHide", function() + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil + end + searchBox:SetText("") + searchBox:ClearFocus() + searchPlaceholder:Show() + end) + + -- Scroll frame + local scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) + scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) + + local scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetWidth(234) + scrollFrame:SetScrollChild(scrollChild) + + StyleScrollBar(scrollFrame) + + local menuButtons = {} + local ITEM_HEIGHT = 22 + local MAX_VISIBLE = 10 + + local function RebuildMenu(filterText) + for _, menuBtn in ipairs(menuButtons) do + menuBtn:Hide() + menuBtn:SetParent(nil) + end + wipe(menuButtons) + + local options = DF:GetSoundList() + local sortedOptions = {} + + filterText = filterText and filterText:lower() or "" + + for k, v in pairs(options) do + if filterText == "" or v:lower():find(filterText, 1, true) then + table.insert(sortedOptions, {key = k, value = v}) + end + end + table.sort(sortedOptions, function(a, b) return a.value < b.value end) + + local menuHeight = math.min(#sortedOptions, MAX_VISIBLE) * ITEM_HEIGHT + SEARCH_HEIGHT + 8 + menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) + menuFrame:SetHeight(menuHeight) + scrollChild:SetHeight(#sortedOptions * ITEM_HEIGHT) + + if scrollBar then + if #sortedOptions <= MAX_VISIBLE then + scrollBar:Hide() + else + scrollBar:Show() + end + end + + for i, opt in ipairs(sortedOptions) do + local menuBtn = CreateFrame("Button", nil, scrollChild) + menuBtn:SetSize(234, ITEM_HEIGHT) + menuBtn:SetPoint("TOPLEFT", 0, -(i - 1) * ITEM_HEIGHT) + + menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + menuBtn.Text:SetPoint("LEFT", 8, 0) + menuBtn.Text:SetPoint("RIGHT", -8, 0) + menuBtn.Text:SetJustifyH("LEFT") + menuBtn.Text:SetText(opt.value) + + -- Highlight selected item + local currentValue = dbTable[dbKey] + if currentValue == opt.key then + menuBtn.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + + menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") + menuBtn.Highlight:SetAllPoints() + local c = GetThemeColor() + menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) + + menuBtn:SetScript("OnClick", function() + dbTable[dbKey] = opt.key + UpdateText() + menuFrame:Hide() + DF:UpdateAll() + if callback then callback() end + end) + + table.insert(menuButtons, menuBtn) + end + end + + searchBox:SetScript("OnTextChanged", function(self) + RebuildMenu(self:GetText()) + end) + + searchBox:SetScript("OnEscapePressed", function() + menuFrame:Hide() + end) + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + btn:SetScript("OnClick", function(self) + if menuFrame:IsShown() then + menuFrame:Hide() + S.currentOpenDropdown = nil + else + CloseOpenDropdown() + RebuildMenu() + menuFrame:Show() + S.currentOpenDropdown = menuFrame + searchBox:SetFocus() + end + end) + + btn:SetScript("OnShow", UpdateText) + UpdateText() + + return container +end + +-- ============================================================ +-- ROLE ORDER LIST (Drag-Drop) +-- ============================================================ + +function GUI:CreateRoleOrderList(parent, dbTable, dbKey, callback, separateMeleeRangedKey) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(220, 130) + + -- Role display info with colors + local ROLE_INFO = { + TANK = { name = L["Tank"], color = {0.53, 0.77, 0.84}, coords = {0, 19/64, 22/64, 41/64} }, + HEALER = { name = L["Healer"], color = {0.25, 0.78, 0.25}, coords = {20/64, 39/64, 1/64, 20/64} }, + MELEE = { name = L["Melee DPS"], color = {0.82, 0.65, 0.47}, coords = {20/64, 39/64, 22/64, 41/64} }, + RANGED = { name = L["Ranged DPS"], color = {1.0, 0.49, 0.04}, coords = {20/64, 39/64, 22/64, 41/64} }, + DAMAGER = { name = L["DPS"], color = {0.82, 0.65, 0.47}, coords = {20/64, 39/64, 22/64, 41/64} }, + } + + local roleItems = {} + -- Snapped stride + gap: a raw 30-unit stride is 42.19 device px, so every + -- row would sit on a different sub-pixel phase and the error would + -- ACCUMULATE down the list (row 3 off by twice row 2). Rows are anchored + -- by two corners, so nothing corrects them after the fact. + local ITEM_HEIGHT = SnapLen(parent, 30) or 30 + local ITEM_GAP = SnapLen(parent, 2) or 2 + local draggingItem = nil + local dragOffsetY = 0 + + -- Check if we should show separate melee/ranged + local function IsSeparateMeleeRanged() + if separateMeleeRangedKey and dbTable then + return dbTable[separateMeleeRangedKey] + end + return true + end + + -- Get the roles to display + local function GetDisplayRoles() + if IsSeparateMeleeRanged() then + return { "TANK", "HEALER", "MELEE", "RANGED" } + else + return { "TANK", "HEALER", "DAMAGER" } + end + end + + -- Get current order from db or use default + local function GetCurrentOrder() + local displayRoles = GetDisplayRoles() + if dbTable and dbKey and dbTable[dbKey] then + local order = {} + for _, role in ipairs(dbTable[dbKey]) do + for _, displayRole in ipairs(displayRoles) do + if role == displayRole or + (displayRole == "DAMAGER" and (role == "MELEE" or role == "RANGED" or role == "DAMAGER")) then + local found = false + for _, existing in ipairs(order) do + if existing == displayRole then found = true break end + end + if not found then + table.insert(order, displayRole) + end + break + end + end + end + for _, displayRole in ipairs(displayRoles) do + local found = false + for _, existing in ipairs(order) do + if existing == displayRole then found = true break end + end + if not found then + table.insert(order, displayRole) + end + end + return order + end + return displayRoles + end + + -- Save order to db + local function SaveOrder(newOrder) + if dbTable and dbKey then + local saveOrder = {} + for _, role in ipairs(newOrder) do + if role == "DAMAGER" then + table.insert(saveOrder, "MELEE") + table.insert(saveOrder, "RANGED") + else + table.insert(saveOrder, role) + end + end + dbTable[dbKey] = saveOrder + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + local copy = {} + for i, v in ipairs(saveOrder) do copy[i] = v end + DF.AutoProfilesUI:SetProfileSetting(dbKey, copy) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(saveOrder) + end + if callback then callback() end + end + end + + -- Get index from Y position + local function GetIndexFromY(y) + local containerTop = container:GetTop() + if not containerTop then return 1 end + local relativeY = containerTop - y + local index = math.floor(relativeY / ITEM_HEIGHT) + 1 + local order = GetCurrentOrder() + return math.max(1, math.min(index, #order)) + end + + -- Update visual positions + local function UpdateItemPositions() + local order = GetCurrentOrder() + local numRoles = #order + + container:SetHeight(numRoles * ITEM_HEIGHT + (SnapLen(container, 5) or 5)) + + for _, item in pairs(roleItems) do + item:Hide() + end + + for i, role in ipairs(order) do + local item = roleItems[role] + if item then + item:Show() + item.posIndex = i + item.numText:SetText(i .. ".") + if item ~= draggingItem then + -- Anchored to BOTH sides: the container is created at a placeholder + -- width and only stretched to its real one by the settings group's + -- LayoutChildren, so a width captured here would be stale. Deriving it + -- from the anchors keeps the rows correct at every layout. + item:ClearAllPoints() + item:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) + item:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) + end + end + end + end + + -- Create grip texture (3 horizontal lines) + local function CreateGripTexture(parentFrame) + local grip = CreateFrame("Frame", nil, parentFrame) + grip:SetSize(12, 16) + + local icon = grip:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints(grip) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") + icon:SetVertexColor(0.5, 0.5, 0.5, 1) + grip.icon = icon + + grip.SetGripColor = function(self, r, g, b) + self.icon:SetVertexColor(r, g, b, 1) + end + + return grip + end + + -- Create a single role item + local function CreateRoleItem(role) + local info = ROLE_INFO[role] + if not info then return nil end + + local item = CreateFrame("Frame", nil, container, "BackdropTemplate") + item:SetHeight(ITEM_HEIGHT - ITEM_GAP) + item:EnableMouse(true) + CreateElementBackdrop(item, { + bgColor = { 0.12, 0.12, 0.12, 0.9 }, + borderColor = { 0.3, 0.3, 0.3, 1 }, + }) + + -- Grip texture + local grip = CreateGripTexture(item) + grip:SetPoint("LEFT", 6, 0) + item.grip = grip + + -- Priority number + local numText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlight") + numText:SetPoint("LEFT", grip, "RIGHT", 6, 0) + numText:SetWidth(18) + numText:SetJustifyH("LEFT") + item.numText = numText + + -- Role icon + local icon = item:CreateTexture(nil, "ARTWORK") + icon:SetSize(16, 16) + icon:SetPoint("LEFT", numText, "RIGHT", 2, 0) + icon:SetTexture("Interface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES") + icon:SetTexCoord(unpack(info.coords)) + item.icon = icon + + -- Role name with color + local text = item:CreateFontString(nil, "OVERLAY", "DFFontNormal") + text:SetPoint("LEFT", icon, "RIGHT", 6, 0) + text:SetText(info.name) + text:SetTextColor(info.color[1], info.color[2], info.color[3]) + item.text = text + + item.role = role + item.posIndex = 1 + + -- Mouse handlers for dragging + item:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + draggingItem = self + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local itemTop = self:GetTop() + dragOffsetY = itemTop - cursorY + + local tc = GetThemeColor() + self:SetBackdropColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) + self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) + self:SetFrameLevel(container:GetFrameLevel() + 10) + self.grip:SetGripColor(1, 1, 1) + end + end) + + item:SetScript("OnMouseUp", function(self, button) + if button == "LeftButton" and draggingItem == self then + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local dropIndex = GetIndexFromY(cursorY) + + local order = GetCurrentOrder() + local currentIdx = self.posIndex + + if currentIdx ~= dropIndex then + local draggedRole = self.role + table.remove(order, currentIdx) + table.insert(order, dropIndex, draggedRole) + SaveOrder(order) + end + + self:SetBackdropColor(0.12, 0.12, 0.12, 0.9) + self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + self:SetFrameLevel(container:GetFrameLevel() + 1) + self.grip:SetGripColor(0.5, 0.5, 0.5) + + draggingItem = nil + UpdateItemPositions() + end + end) + + item:SetScript("OnUpdate", function(self) + if draggingItem ~= self then return end + + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local containerTop = container:GetTop() + local containerBottom = container:GetBottom() + + if not containerTop or not containerBottom then return end + + local targetY = cursorY + dragOffsetY + local offsetFromTop = containerTop - targetY + + local maxOffset = (containerTop - containerBottom) - ITEM_HEIGHT + 5 + offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) + + self:ClearAllPoints() + self:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -offsetFromTop) + self:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -offsetFromTop) + + -- Update other items based on where this would drop + local dropIndex = GetIndexFromY(cursorY) + local order = GetCurrentOrder() + + local tempOrder = {} + for i, r in ipairs(order) do + if roleItems[r] ~= self then + table.insert(tempOrder, r) + end + end + table.insert(tempOrder, dropIndex, self.role) + + for i, r in ipairs(tempOrder) do + local otherItem = roleItems[r] + if otherItem and otherItem ~= self then + otherItem:ClearAllPoints() + otherItem:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) + otherItem:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) + otherItem.numText:SetText(i .. ".") + end + end + end) + + -- Hover effects + item:SetScript("OnEnter", function(self) + if not draggingItem then + self:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) + self.grip:SetGripColor(0.8, 0.8, 0.8) + end + end) + + item:SetScript("OnLeave", function(self) + if draggingItem ~= self then + self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + self.grip:SetGripColor(0.5, 0.5, 0.5) + end + end) + + return item + end + + -- Create all role items + for _, role in ipairs({"TANK", "HEALER", "MELEE", "RANGED", "DAMAGER"}) do + roleItems[role] = CreateRoleItem(role) + end + + -- Initial layout + UpdateItemPositions() + + -- Refresh function + container.Refresh = function() + UpdateItemPositions() + end + + -- Override indicators for profile editing + if dbKey and type(dbKey) == "string" and not (dbTable and rawget(dbTable, "_skipOverrideIndicators")) then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and type(globalVal) == "table" then + local copy = {} + for i, v in ipairs(globalVal) do copy[i] = v end + dbTable[dbKey] = copy + end + UpdateItemPositions() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable[dbKey]) + end + if callback then callback() end + end + end + AddOrderListOverrideIndicators(container, dbKey, onReset) + + container:SetScript("OnShow", function() + UpdateItemPositions() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + end) + end + + return container +end + +-- ============================================================ +-- CLASS ORDER LIST (Drag-Drop) - For class sorting within roles +-- ============================================================ + +function GUI:CreateClassOrderList(parent, dbTable, dbKey, callback) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(220, 340) -- Taller to fit all 13 classes + + -- Class display info with colors (using Blizzard class colors) + local CLASS_INFO = { + DEATHKNIGHT = { name = L["Death Knight"], color = {0.77, 0.12, 0.23} }, + DEMONHUNTER = { name = L["Demon Hunter"], color = {0.64, 0.19, 0.79} }, + DRUID = { name = L["Druid"], color = {1.0, 0.49, 0.04} }, + EVOKER = { name = L["Evoker"], color = {0.20, 0.58, 0.50} }, + HUNTER = { name = L["Hunter"], color = {0.67, 0.83, 0.45} }, + MAGE = { name = L["Mage"], color = {0.25, 0.78, 0.92} }, + MONK = { name = L["Monk"], color = {0.0, 1.0, 0.59} }, + PALADIN = { name = L["Paladin"], color = {0.96, 0.55, 0.73} }, + PRIEST = { name = L["Priest"], color = {1.0, 1.0, 1.0} }, + ROGUE = { name = L["Rogue"], color = {1.0, 0.96, 0.41} }, + SHAMAN = { name = L["Shaman"], color = {0.0, 0.44, 0.87} }, + WARLOCK = { name = L["Warlock"], color = {0.53, 0.53, 0.93} }, + WARRIOR = { name = L["Warrior"], color = {0.78, 0.61, 0.43} }, + } + + local ALL_CLASSES = { + "DEATHKNIGHT", "DEMONHUNTER", "DRUID", "EVOKER", "HUNTER", + "MAGE", "MONK", "PALADIN", "PRIEST", "ROGUE", + "SHAMAN", "WARLOCK", "WARRIOR" + } + + local classItems = {} + -- Snapped; see CreateRoleOrderList. + local ITEM_HEIGHT = SnapLen(parent, 24) or 24 -- smaller, to fit all classes + local ITEM_GAP = SnapLen(parent, 2) or 2 + local draggingItem = nil + local dragOffsetY = 0 + + -- Get current order from db or use default + local function GetCurrentOrder() + if dbTable and dbKey and dbTable[dbKey] then + -- Ensure all classes are present + local order = {} + local seen = {} + for _, class in ipairs(dbTable[dbKey]) do + if CLASS_INFO[class] and not seen[class] then + table.insert(order, class) + seen[class] = true + end + end + -- Add any missing classes + for _, class in ipairs(ALL_CLASSES) do + if not seen[class] then + table.insert(order, class) + end + end + return order + end + return ALL_CLASSES + end + + -- Save order to db + local function SaveOrder(newOrder) + if dbTable and dbKey then + dbTable[dbKey] = newOrder + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + local copy = {} + for i, v in ipairs(newOrder) do copy[i] = v end + DF.AutoProfilesUI:SetProfileSetting(dbKey, copy) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(newOrder) + end + if callback then callback() end + end + end + + -- Get index from Y position + local function GetIndexFromY(y) + local containerTop = container:GetTop() + if not containerTop then return 1 end + local relativeY = containerTop - y + local index = math.floor(relativeY / ITEM_HEIGHT) + 1 + local order = GetCurrentOrder() + return math.max(1, math.min(index, #order)) + end + + -- Update visual positions + local function UpdateItemPositions() + local order = GetCurrentOrder() + local numClasses = #order + + container:SetHeight(numClasses * ITEM_HEIGHT + (SnapLen(container, 5) or 5)) + + for _, item in pairs(classItems) do + item:Hide() + end + + for i, class in ipairs(order) do + local item = classItems[class] + if item then + item:Show() + item.posIndex = i + item.numText:SetText(i .. ".") + if item ~= draggingItem then + -- Anchored to BOTH sides: the container is created at a placeholder + -- width and only stretched to its real one by the settings group's + -- LayoutChildren, so a width captured here would be stale. Deriving it + -- from the anchors keeps the rows correct at every layout. + item:ClearAllPoints() + item:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) + item:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) + end + end + end + end + + -- Create grip texture (3 horizontal lines) + local function CreateGripTexture(parentFrame) + local grip = CreateFrame("Frame", nil, parentFrame) + grip:SetSize(10, 12) + + local icon = grip:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints(grip) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") + icon:SetVertexColor(0.5, 0.5, 0.5, 1) + grip.icon = icon + + grip.SetGripColor = function(self, r, g, b) + self.icon:SetVertexColor(r, g, b, 1) + end + + return grip + end + + -- Create a single class item + local function CreateClassItem(class) + local info = CLASS_INFO[class] + if not info then return nil end + + local item = CreateFrame("Frame", nil, container, "BackdropTemplate") + item:SetHeight(ITEM_HEIGHT - ITEM_GAP) + item:EnableMouse(true) + CreateElementBackdrop(item, { + bgColor = { 0.12, 0.12, 0.12, 0.9 }, + borderColor = { 0.3, 0.3, 0.3, 1 }, + }) + + -- Grip texture + local grip = CreateGripTexture(item) + grip:SetPoint("LEFT", 4, 0) + item.grip = grip + + -- Priority number + local numText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + numText:SetPoint("LEFT", grip, "RIGHT", 4, 0) + numText:SetWidth(20) + numText:SetJustifyH("LEFT") + item.numText = numText + + -- Class color bar + local colorBar = item:CreateTexture(nil, "ARTWORK") + colorBar:SetSize(3, ITEM_HEIGHT - 6) + colorBar:SetPoint("LEFT", numText, "RIGHT", 2, 0) + colorBar:SetColorTexture(info.color[1], info.color[2], info.color[3], 1) + item.colorBar = colorBar + + -- Class name with color + local text = item:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") + text:SetPoint("LEFT", colorBar, "RIGHT", 6, 0) + text:SetText(info.name) + text:SetTextColor(info.color[1], info.color[2], info.color[3]) + item.text = text + + item.class = class + item.posIndex = 1 + + -- Mouse handlers for dragging + item:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + draggingItem = self + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local itemTop = self:GetTop() + dragOffsetY = itemTop - cursorY + + local tc = GetThemeColor() + self:SetBackdropColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) + self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) + self:SetFrameLevel(container:GetFrameLevel() + 10) + self.grip:SetGripColor(1, 1, 1) + end + end) + + item:SetScript("OnMouseUp", function(self, button) + if button == "LeftButton" and draggingItem == self then + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local dropIndex = GetIndexFromY(cursorY) + + local order = GetCurrentOrder() + local currentIdx = self.posIndex + + if currentIdx ~= dropIndex then + local draggedClass = self.class + table.remove(order, currentIdx) + table.insert(order, dropIndex, draggedClass) + SaveOrder(order) + end + + self:SetBackdropColor(0.12, 0.12, 0.12, 0.9) + self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + self:SetFrameLevel(container:GetFrameLevel() + 1) + self.grip:SetGripColor(0.5, 0.5, 0.5) + + draggingItem = nil + UpdateItemPositions() + end + end) + + item:SetScript("OnUpdate", function(self) + if draggingItem ~= self then return end + + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local containerTop = container:GetTop() + local containerBottom = container:GetBottom() + + if not containerTop or not containerBottom then return end + + local targetY = cursorY + dragOffsetY + local offsetFromTop = containerTop - targetY + + local maxOffset = (containerTop - containerBottom) - ITEM_HEIGHT + 5 + offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) + + self:ClearAllPoints() + self:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -offsetFromTop) + self:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -offsetFromTop) + + -- Update other items based on where this would drop + local dropIndex = GetIndexFromY(cursorY) + local order = GetCurrentOrder() + + local tempOrder = {} + for i, c in ipairs(order) do + if classItems[c] ~= self then + table.insert(tempOrder, c) + end + end + table.insert(tempOrder, dropIndex, self.class) + + for i, c in ipairs(tempOrder) do + local otherItem = classItems[c] + if otherItem and otherItem ~= self then + otherItem:ClearAllPoints() + otherItem:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) + otherItem:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) + otherItem.numText:SetText(i .. ".") + end + end + end) + + -- Hover effects + item:SetScript("OnEnter", function(self) + if not draggingItem then + self:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) + self.grip:SetGripColor(0.8, 0.8, 0.8) + end + end) + + item:SetScript("OnLeave", function(self) + if draggingItem ~= self then + self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + self.grip:SetGripColor(0.5, 0.5, 0.5) + end + end) + + return item + end + + -- Create all class items + for _, class in ipairs(ALL_CLASSES) do + classItems[class] = CreateClassItem(class) + end + + -- Initial layout + UpdateItemPositions() + + -- Refresh function + container.Refresh = function() + UpdateItemPositions() + end + + -- Override indicators for profile editing + if dbKey and type(dbKey) == "string" and not (dbTable and rawget(dbTable, "_skipOverrideIndicators")) then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and type(globalVal) == "table" then + local copy = {} + for i, v in ipairs(globalVal) do copy[i] = v end + dbTable[dbKey] = copy + end + UpdateItemPositions() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable[dbKey]) + end + if callback then callback() end + end + end + AddOrderListOverrideIndicators(container, dbKey, onReset) + + container:SetScript("OnShow", function() + UpdateItemPositions() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + end) + end + + return container +end + +-- Raid Group Order List (drag-and-drop) +function GUI:CreateGroupOrderList(parent, dbTable, dbKey, callback, playerGroupFirstKey) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(180, 250) + + -- Group colors for visual distinction + local GROUP_COLORS = { + [1] = {0.95, 0.40, 0.40}, -- Red + [2] = {0.40, 0.95, 0.40}, -- Green + [3] = {0.40, 0.60, 0.95}, -- Blue + [4] = {0.95, 0.95, 0.40}, -- Yellow + [5] = {0.95, 0.40, 0.95}, -- Magenta + [6] = {0.40, 0.95, 0.95}, -- Cyan + [7] = {0.95, 0.70, 0.40}, -- Orange + [8] = {0.70, 0.40, 0.95}, -- Purple + } + + local groupItems = {} + -- Snapped; see CreateRoleOrderList. + local ITEM_HEIGHT = SnapLen(parent, 28) or 28 + local ITEM_GAP = SnapLen(parent, 2) or 2 + local draggingItem = nil + local dragOffsetY = 0 + + -- Get current order from db or use default + local function GetCurrentOrder() + if dbTable and dbKey and dbTable[dbKey] then + -- Validate and return existing order + local order = {} + local seen = {} + for _, groupNum in ipairs(dbTable[dbKey]) do + if groupNum >= 1 and groupNum <= 8 and not seen[groupNum] then + table.insert(order, groupNum) + seen[groupNum] = true + end + end + -- Add any missing groups + for i = 1, 8 do + if not seen[i] then + table.insert(order, i) + end + end + return order + end + return {1, 2, 3, 4, 5, 6, 7, 8} + end + + -- Save order to db + local function SaveOrder(newOrder) + if dbTable and dbKey then + dbTable[dbKey] = newOrder + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + local copy = {} + for i, v in ipairs(newOrder) do copy[i] = v end + DF.AutoProfilesUI:SetProfileSetting(dbKey, copy) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(newOrder) + end + if callback then callback() end + end + end + + -- Get index from Y position + local function GetIndexFromY(y) + local containerTop = container:GetTop() + if not containerTop then return 1 end + local relativeY = containerTop - y + local index = math.floor(relativeY / ITEM_HEIGHT) + 1 + return math.max(1, math.min(index, 8)) + end + + -- Update visual positions + local function UpdateItemPositions() + local order = GetCurrentOrder() + + for _, item in pairs(groupItems) do + item:Hide() + end + + for displayPos, groupNum in ipairs(order) do + local item = groupItems[groupNum] + if item then + item:Show() + item.displayPos = displayPos + item.numText:SetText(displayPos .. ".") + if item ~= draggingItem then + -- Anchored to BOTH sides: the container is created at a placeholder + -- width and only stretched to its real one by the settings group's + -- LayoutChildren, so a width captured here would be stale. Deriving it + -- from the anchors keeps the rows correct at every layout. + item:ClearAllPoints() + item:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((displayPos - 1) * ITEM_HEIGHT)) + item:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((displayPos - 1) * ITEM_HEIGHT)) + end + end + end + end + + -- Create grip texture (3 horizontal lines) + local function CreateGripTexture(parentFrame) + local grip = CreateFrame("Frame", nil, parentFrame) + grip:SetSize(12, 14) + + local icon = grip:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints(grip) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") + icon:SetVertexColor(0.5, 0.5, 0.5, 1) + grip.icon = icon + + grip.SetGripColor = function(self, r, g, b) + self.icon:SetVertexColor(r, g, b, 1) + end + + return grip + end + + -- Create a single group item + local function CreateGroupItem(groupNum) + local color = GROUP_COLORS[groupNum] + + local item = CreateFrame("Frame", nil, container, "BackdropTemplate") + item:SetHeight(ITEM_HEIGHT - ITEM_GAP) + item:EnableMouse(true) + CreateElementBackdrop(item, { + bgColor = { 0.12, 0.12, 0.12, 0.9 }, + borderColor = { 0.3, 0.3, 0.3, 1 }, + }) + + -- Grip texture + local grip = CreateGripTexture(item) + grip:SetPoint("LEFT", 6, 0) + item.grip = grip + + -- Display position number + local numText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlight") + numText:SetPoint("LEFT", grip, "RIGHT", 6, 0) + numText:SetWidth(18) + numText:SetJustifyH("LEFT") + item.numText = numText + + -- Color swatch + local swatch = item:CreateTexture(nil, "ARTWORK") + swatch:SetSize(14, 14) + swatch:SetPoint("LEFT", numText, "RIGHT", 4, 0) + swatch:SetColorTexture(color[1], color[2], color[3], 1) + item.swatch = swatch + + -- Group name + local text = item:CreateFontString(nil, "OVERLAY", "DFFontNormal") + text:SetPoint("LEFT", swatch, "RIGHT", 6, 0) + text:SetText(string.format(L["Group %d"], groupNum)) + text:SetTextColor(color[1], color[2], color[3]) + item.text = text + + item.groupNum = groupNum + item.displayPos = groupNum + + -- Mouse handlers for dragging + item:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + draggingItem = self + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local itemTop = self:GetTop() + dragOffsetY = itemTop - cursorY + + local tc = GetThemeColor() + self:SetBackdropColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) + self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) + self:SetFrameLevel(container:GetFrameLevel() + 10) + self.grip:SetGripColor(1, 1, 1) + end + end) + + item:SetScript("OnMouseUp", function(self, button) + if button == "LeftButton" and draggingItem == self then + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local newIndex = GetIndexFromY(cursorY) + + -- Reorder + local currentOrder = GetCurrentOrder() + local oldIndex = self.displayPos + + if newIndex ~= oldIndex then + table.remove(currentOrder, oldIndex) + table.insert(currentOrder, newIndex, self.groupNum) + SaveOrder(currentOrder) + end + + draggingItem = nil + self:SetBackdropColor(0.12, 0.12, 0.12, 0.9) + self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + self:SetFrameLevel(container:GetFrameLevel() + 1) + self.grip:SetGripColor(0.5, 0.5, 0.5) + + UpdateItemPositions() + end + end) + + item:SetScript("OnUpdate", function(self) + if draggingItem ~= self then return end + + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local containerTop = container:GetTop() + local containerBottom = container:GetBottom() + + if not containerTop or not containerBottom then return end + + local targetY = cursorY + dragOffsetY + local offsetFromTop = containerTop - targetY + + local maxOffset = (containerTop - containerBottom) - ITEM_HEIGHT + 5 + offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) + + self:ClearAllPoints() + self:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -offsetFromTop) + self:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -offsetFromTop) + + -- Update other items based on where this would drop + local dropIndex = GetIndexFromY(cursorY) + local order = GetCurrentOrder() + + -- Build temp order: remove self, insert at drop position + local tempOrder = {} + for i, g in ipairs(order) do + if groupItems[g] ~= self then + table.insert(tempOrder, g) + end + end + table.insert(tempOrder, dropIndex, self.groupNum) + + -- Position all other items according to temp order + for i, g in ipairs(tempOrder) do + local otherItem = groupItems[g] + if otherItem and otherItem ~= self then + otherItem:ClearAllPoints() + otherItem:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) + otherItem:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) + otherItem.numText:SetText(i .. ".") + end + end + end) + + item:SetScript("OnEnter", function(self) + if draggingItem ~= self then + self:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) + self.grip:SetGripColor(0.8, 0.8, 0.8) + end + end) + + item:SetScript("OnLeave", function(self) + if draggingItem ~= self then + self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + self.grip:SetGripColor(0.5, 0.5, 0.5) + end + end) + + return item + end + + -- Create all group items + for i = 1, 8 do + groupItems[i] = CreateGroupItem(i) + end + + -- Initial layout + UpdateItemPositions() + + -- Refresh function + container.Refresh = function() + UpdateItemPositions() + end + + -- Override indicators for profile editing + if dbKey and type(dbKey) == "string" and not (dbTable and rawget(dbTable, "_skipOverrideIndicators")) then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and type(globalVal) == "table" then + local copy = {} + for i, v in ipairs(globalVal) do copy[i] = v end + dbTable[dbKey] = copy + end + UpdateItemPositions() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable[dbKey]) + end + if callback then callback() end + end + end + AddOrderListOverrideIndicators(container, dbKey, onReset) + + container:SetScript("OnShow", function() + UpdateItemPositions() + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + end) + end + + return container +end + +-- ============================================================ +-- HIGHLIGHT FRAMES ROSTER WIDGET +-- ============================================================ +-- Dual-column widget for selecting players to highlight +-- Left: Current group roster +-- Right: Selected players (draggable for reorder) + +function GUI:CreateHighlightRosterWidget(parent, getPlayersFunc, setPlayersFunc, onChangeCallback) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(460, 340) + + -- Snapped; see CreateRoleOrderList. + local ITEM_HEIGHT = SnapLen(parent, 26) or 26 + local ITEM_GAP = SnapLen(parent, 2) or 2 + local COL_WIDTH = 224 -- Wider columns + local COL_GAP = 12 -- Smaller gap between columns + + -- State + local rosterItems = {} + local highlightItems = {} + local currentRoster = {} + local draggingItem = nil + local dragOffsetY = 0 + + -- Custom role icons + local ROLE_ICONS = { + TANK = "Interface\\AddOns\\DandersFrames\\Media\\DF_Tank", + HEALER = "Interface\\AddOns\\DandersFrames\\Media\\DF_Healer", + DAMAGER = "Interface\\AddOns\\DandersFrames\\Media\\DF_DPS", + } + local ROLE_COLORS = { + TANK = {0.35, 0.56, 0.82}, + HEALER = {0.29, 0.62, 0.29}, + DAMAGER = {0.70, 0.35, 0.35}, + } + + -- Icon paths + local ICON_ARROW = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right" + local ICON_CHECK = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\check" + local ICON_CLOSE = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close" + + -- ========== LEFT COLUMN: Group Roster ========== + local leftHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") + leftHeader:SetPoint("TOPLEFT", 0, 0) + leftHeader:SetText(L["Group Roster"]) + leftHeader:SetTextColor(0.7, 0.7, 0.7) + + local leftCount = container:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") + leftCount:SetPoint("LEFT", leftHeader, "RIGHT", 8, 0) + leftCount:SetTextColor(0.5, 0.5, 0.5) + + local leftBg = CreateFrame("Frame", nil, container, "BackdropTemplate") + leftBg:SetPoint("TOPLEFT", 0, -18) + leftBg:SetSize(COL_WIDTH, 240) + GUI:CreateElementBackdrop(leftBg, { bgColor = GUI.Colors.background }) + + local leftScroll = CreateFrame("ScrollFrame", nil, leftBg, "ScrollFrameTemplate") + leftScroll:SetPoint("TOPLEFT", 4, -4) + leftScroll:SetPoint("BOTTOMRIGHT", -24, 4) + + local leftContent = CreateFrame("Frame", nil, leftScroll) + leftContent:SetSize(COL_WIDTH - 28, 1) + leftScroll:SetScrollChild(leftContent) + StyleScrollBar(leftScroll) + + -- ========== RIGHT COLUMN: Pinned Units ========== + local rightHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") + rightHeader:SetPoint("TOPLEFT", leftBg, "TOPRIGHT", COL_GAP, 18) + rightHeader:SetText(L["Pinned Units"]) + rightHeader:SetTextColor(0.7, 0.7, 0.7) + + local rightCount = container:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") + rightCount:SetPoint("LEFT", rightHeader, "RIGHT", 8, 0) + rightCount:SetTextColor(0.5, 0.5, 0.5) + + local rightBg = CreateFrame("Frame", nil, container, "BackdropTemplate") + rightBg:SetPoint("TOPLEFT", leftBg, "TOPRIGHT", COL_GAP, 0) + rightBg:SetSize(COL_WIDTH, 240) + GUI:CreateElementBackdrop(rightBg, { bgColor = GUI.Colors.background }) + + local rightScroll = CreateFrame("ScrollFrame", nil, rightBg, "ScrollFrameTemplate") + rightScroll:SetPoint("TOPLEFT", 4, -4) + rightScroll:SetPoint("BOTTOMRIGHT", -24, 4) + + local rightContent = CreateFrame("Frame", nil, rightScroll) + rightContent:SetSize(COL_WIDTH - 28, 1) + rightScroll:SetScrollChild(rightContent) + StyleScrollBar(rightScroll) + + -- ========== HELPER FUNCTIONS ========== + + -- Get current group roster + local function GetGroupRoster() + local roster = {} + local numMembers = GetNumGroupMembers() + if numMembers == 0 then + -- Solo - just show player + local name = UnitName("player") + local realm = GetRealmName() + local _, class = UnitClass("player") + table.insert(roster, { + name = name, + fullName = name .. "-" .. realm, + class = class or "WARRIOR", + role = "DAMAGER", + group = 1, + }) + return roster + end + + local isRaid = IsInRaid() + + for i = 1, numMembers do + local unit = isRaid and ("raid" .. i) or (i == 1 and "player" or "party" .. (i - 1)) + local name, realm = UnitName(unit) + + if name then + realm = realm or GetRealmName() + local fullName = name .. "-" .. realm + local _, class = UnitClass(unit) + local role = UnitGroupRolesAssigned(unit) + if role == "NONE" then role = "DAMAGER" end + local group = 1 + if isRaid then + local raidIndex = UnitInRaid(unit) + if raidIndex then + local _, _, subgroup = GetRaidRosterInfo(raidIndex + 1) + group = subgroup or 1 + end + end + + table.insert(roster, { + name = name, + fullName = fullName, + class = class or "WARRIOR", + role = role or "DAMAGER", + group = group, + }) + end + end + + -- Sort by group, then role, then name + table.sort(roster, function(a, b) + if a.group ~= b.group then return a.group < b.group end + local roleOrder = { TANK = 1, HEALER = 2, DAMAGER = 3 } + local aRole = roleOrder[a.role] or 3 + local bRole = roleOrder[b.role] or 3 + if aRole ~= bRole then return aRole < bRole end + return a.name < b.name + end) + + return roster + end + + -- Check if player is in highlighted list + local function IsPlayerHighlighted(fullName) + local players = getPlayersFunc() + for _, p in ipairs(players) do + if p == fullName then return true end + end + return false + end + + -- Check if player is in current group + local function IsPlayerInGroup(fullName) + for _, p in ipairs(currentRoster) do + if p.fullName == fullName or p.name == fullName then + return true, p + end + end + return false, nil + end + + -- Add player to highlight list + local function AddPlayer(fullName) + local players = getPlayersFunc() + if not IsPlayerHighlighted(fullName) then + table.insert(players, fullName) + setPlayersFunc(players) + if onChangeCallback then onChangeCallback() end + end + end + + -- Remove player from highlight list + local function RemovePlayer(fullName) + local players = getPlayersFunc() + for i, p in ipairs(players) do + if p == fullName then + table.remove(players, i) + setPlayersFunc(players) + if onChangeCallback then onChangeCallback() end + break + end + end + end + + -- Create grip texture + local function CreateGripTexture(parentFrame) + local grip = CreateFrame("Frame", nil, parentFrame) + grip:SetSize(12, 14) + + local icon = grip:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints(grip) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") + icon:SetVertexColor(0.5, 0.5, 0.5, 1) + grip.icon = icon + + grip.SetGripColor = function(self, r, g, b) + self.icon:SetVertexColor(r, g, b, 1) + end + + return grip + end + + -- Create role icon using custom textures + local function CreateRoleIcon(parentFrame, role) + local icon = parentFrame:CreateTexture(nil, "ARTWORK") + icon:SetSize(14, 14) + icon:SetTexture(ROLE_ICONS[role] or ROLE_ICONS.DAMAGER) + return icon + end + + -- ========== ROSTER ITEM (Left Column) ========== + local function CreateRosterItem(playerData, index) + local item = CreateFrame("Frame", nil, leftContent, "BackdropTemplate") + item:SetHeight(ITEM_HEIGHT - ITEM_GAP) + item:SetPoint("TOPLEFT", 0, -((index - 1) * ITEM_HEIGHT)) + item:SetPoint("TOPRIGHT", 0, -((index - 1) * ITEM_HEIGHT)) + -- Transparent plate: the hover/selected states tint it, so it needs a fill + -- to colour but no outline of its own. + CreateElementBackdrop(item, { outline = false, bgColor = { 0, 0, 0, 0 } }) + + item.playerData = playerData + + -- Role icon + local roleIcon = CreateRoleIcon(item, playerData.role) + roleIcon:SetPoint("LEFT", 4, 0) + item.roleIcon = roleIcon + + -- Name (class colored) + local nameText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameText:SetPoint("LEFT", roleIcon, "RIGHT", 6, 0) + nameText:SetPoint("RIGHT", -70, 0) + nameText:SetJustifyH("LEFT") + nameText:SetText(playerData.name) + local classColor = DF:GetClassColor(playerData.class) + if classColor then + nameText:SetTextColor(classColor.r, classColor.g, classColor.b) + else + nameText:SetTextColor(0.8, 0.8, 0.8) + end + item.nameText = nameText + + -- Group number + local groupText = item:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") + groupText:SetPoint("RIGHT", -34, 0) + groupText:SetText("G" .. playerData.group) + groupText:SetTextColor(0.4, 0.4, 0.4) + item.groupText = groupText + + -- Add button + local addBtn = CreateFrame("Button", nil, item, "BackdropTemplate") + addBtn:SetSize(26, 20) + addBtn:SetPoint("RIGHT", -4, 0) + -- UpdateAddButton (called below, and on every state change) owns both + -- colours, so this only supplies the chrome. + CreateElementBackdrop(addBtn) + + local themeColor = GetThemeColor() + + -- Icon for button + addBtn.icon = addBtn:CreateTexture(nil, "OVERLAY") + addBtn.icon:SetSize(12, 12) + addBtn.icon:SetPoint("CENTER", 0, 0) + + local function UpdateAddButton() + local isHighlighted = IsPlayerHighlighted(playerData.fullName) + if isHighlighted then + addBtn:SetBackdropColor(0.15, 0.15, 0.15, 0.8) + addBtn:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5) + addBtn.icon:SetTexture(ICON_CHECK) + addBtn.icon:SetVertexColor(0.4, 0.4, 0.4) + item:SetBackdropColor(0.1, 0.1, 0.1, 0.5) + nameText:SetAlpha(0.5) + groupText:SetAlpha(0.5) + roleIcon:SetAlpha(0.5) + else + addBtn:SetBackdropColor(themeColor.r * 0.2, themeColor.g * 0.2, themeColor.b * 0.2, 0.8) + addBtn:SetBackdropBorderColor(themeColor.r * 0.5, themeColor.g * 0.5, themeColor.b * 0.5, 0.8) + addBtn.icon:SetTexture(ICON_ARROW) + addBtn.icon:SetVertexColor(themeColor.r, themeColor.g, themeColor.b) + item:SetBackdropColor(0, 0, 0, 0) + nameText:SetAlpha(1) + groupText:SetAlpha(1) + roleIcon:SetAlpha(1) + end + end + + addBtn:SetScript("OnClick", function() + if not IsPlayerHighlighted(playerData.fullName) then + AddPlayer(playerData.fullName) + container:Refresh() + end + end) + + addBtn:SetScript("OnEnter", function(self) + if not IsPlayerHighlighted(playerData.fullName) then + self:SetBackdropColor(themeColor.r * 0.3, themeColor.g * 0.3, themeColor.b * 0.3, 1) + self:SetBackdropBorderColor(themeColor.r, themeColor.g, themeColor.b, 1) + end + end) + + addBtn:SetScript("OnLeave", function(self) + UpdateAddButton() + end) + + item.addBtn = addBtn + item.UpdateAddButton = UpdateAddButton + UpdateAddButton() + + return item + end + + -- ========== HIGHLIGHT ITEM (Right Column - Draggable) ========== + local function CreateHighlightItem(fullName, index, totalCount) + local item = CreateFrame("Frame", nil, rightContent, "BackdropTemplate") + item:SetHeight(ITEM_HEIGHT - ITEM_GAP) + item:SetPoint("TOPLEFT", 0, -((index - 1) * ITEM_HEIGHT)) + item:SetPoint("TOPRIGHT", 0, -((index - 1) * ITEM_HEIGHT)) + item:EnableMouse(true) + CreateElementBackdrop(item, { + bgColor = { 0.12, 0.12, 0.12, 0.9 }, + borderColor = { 0.25, 0.25, 0.25, 1 }, + }) + + item.fullName = fullName + item.index = index + + -- Check if player is in current group + local inGroup, playerData = IsPlayerInGroup(fullName) + + -- Grip handle + local grip = CreateGripTexture(item) + grip:SetPoint("LEFT", 4, 0) + item.grip = grip + + -- Position number + local themeColor = GetThemeColor() + local numText = item:CreateFontString(nil, "OVERLAY", "DFFontNormal") + numText:SetPoint("LEFT", grip, "RIGHT", 6, 0) + numText:SetWidth(20) + numText:SetJustifyH("LEFT") + numText:SetText(index .. ".") + numText:SetTextColor(themeColor.r, themeColor.g, themeColor.b) + item.numText = numText + + -- Role icon + local role = playerData and playerData.role or "DAMAGER" + local roleIcon = CreateRoleIcon(item, role) + roleIcon:SetPoint("LEFT", numText, "RIGHT", 4, 0) + item.roleIcon = roleIcon + + -- Name + local displayName = fullName:match("([^%-]+)") or fullName -- Get name before realm + local nameText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameText:SetPoint("LEFT", roleIcon, "RIGHT", 6, 0) + nameText:SetPoint("RIGHT", -34, 0) + nameText:SetJustifyH("LEFT") + + if playerData then + nameText:SetText(playerData.name) + local classColor = DF:GetClassColor(playerData.class) + if classColor then + nameText:SetTextColor(classColor.r, classColor.g, classColor.b) + end + else + -- Player not in group + nameText:SetText(displayName .. " " .. L["(offline)"]) + nameText:SetTextColor(0.5, 0.5, 0.5) + item:SetBackdropColor(0.1, 0.1, 0.1, 0.7) + grip:SetGripColor(0.35, 0.35, 0.35) + roleIcon:SetAlpha(0.5) + end + item.nameText = nameText + + -- Remove button + local removeBtn = CreateFrame("Button", nil, item, "BackdropTemplate") + removeBtn:SetSize(26, 20) + removeBtn:SetPoint("RIGHT", -4, 0) + CreateElementBackdrop(removeBtn, { + bgColor = { 0.5, 0.15, 0.15, 0.5 }, + borderColor = { 0.6, 0.25, 0.25, 0.8 }, + }) + + -- X icon for remove button + removeBtn.icon = removeBtn:CreateTexture(nil, "OVERLAY") + removeBtn.icon:SetSize(12, 12) + removeBtn.icon:SetPoint("CENTER", 0, 0) + removeBtn.icon:SetTexture(ICON_CLOSE) + removeBtn.icon:SetVertexColor(0.8, 0.3, 0.3) + + removeBtn:SetScript("OnClick", function() + RemovePlayer(fullName) + container:Refresh() + end) + + removeBtn:SetScript("OnEnter", function(self) + self:SetBackdropColor(0.6, 0.2, 0.2, 0.8) + self:SetBackdropBorderColor(0.8, 0.3, 0.3, 1) + end) + + removeBtn:SetScript("OnLeave", function(self) + self:SetBackdropColor(0.5, 0.15, 0.15, 0.5) + self:SetBackdropBorderColor(0.6, 0.25, 0.25, 0.8) + end) + + item.removeBtn = removeBtn + + -- ========== DRAG HANDLERS ========== + item:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + draggingItem = self + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local itemTop = self:GetTop() + dragOffsetY = itemTop - cursorY + + self:SetBackdropColor(0.25, 0.25, 0.4, 0.95) + self:SetBackdropBorderColor(themeColor.r, themeColor.g, themeColor.b, 1) + self:SetFrameLevel(rightContent:GetFrameLevel() + 10) + self.grip:SetGripColor(1, 1, 1) + end + end) + + item:SetScript("OnMouseUp", function(self, button) + if button == "LeftButton" and draggingItem == self then + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local contentTop = rightContent:GetTop() + if contentTop then + local relativeY = contentTop - cursorY + local newIndex = math.floor(relativeY / ITEM_HEIGHT) + 1 + newIndex = math.max(1, math.min(newIndex, totalCount)) + + local oldIndex = self.index + if newIndex ~= oldIndex then + -- Reorder the players array + local players = getPlayersFunc() + local removed = table.remove(players, oldIndex) + table.insert(players, newIndex, removed) + setPlayersFunc(players) + if onChangeCallback then onChangeCallback() end + end + end + + draggingItem = nil + container:Refresh() + end + end) + + item:SetScript("OnUpdate", function(self) + if draggingItem ~= self then return end + + local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() + local contentTop = rightContent:GetTop() + local contentBottom = rightContent:GetBottom() + + if not contentTop or not contentBottom then return end + + local targetY = cursorY + dragOffsetY + local offsetFromTop = contentTop - targetY + + local maxOffset = math.max(0, (totalCount - 1) * ITEM_HEIGHT) + offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) + + self:ClearAllPoints() + self:SetPoint("TOPLEFT", rightContent, "TOPLEFT", 0, -offsetFromTop) + self:SetPoint("TOPRIGHT", rightContent, "TOPRIGHT", 0, -offsetFromTop) + + -- Update visual positions of other items + local dropIndex = math.floor(offsetFromTop / ITEM_HEIGHT) + 1 + dropIndex = math.max(1, math.min(dropIndex, totalCount)) + + for _, otherItem in ipairs(highlightItems) do + if otherItem ~= self then + local visualIndex = otherItem.index + if self.index < dropIndex then + -- Dragging down + if otherItem.index > self.index and otherItem.index <= dropIndex then + visualIndex = otherItem.index - 1 + end + else + -- Dragging up + if otherItem.index < self.index and otherItem.index >= dropIndex then + visualIndex = otherItem.index + 1 + end + end + otherItem:ClearAllPoints() + otherItem:SetPoint("TOPLEFT", rightContent, "TOPLEFT", 0, -((visualIndex - 1) * ITEM_HEIGHT)) + otherItem:SetPoint("TOPRIGHT", rightContent, "TOPRIGHT", 0, -((visualIndex - 1) * ITEM_HEIGHT)) + otherItem.numText:SetText(visualIndex .. ".") + end + end + end) + + -- Hover effects + item:SetScript("OnEnter", function(self) + if draggingItem ~= self then + self:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) + self.grip:SetGripColor(0.8, 0.8, 0.8) + end + end) + + item:SetScript("OnLeave", function(self) + if draggingItem ~= self then + self:SetBackdropBorderColor(0.25, 0.25, 0.25, 1) + if inGroup then + self.grip:SetGripColor(0.5, 0.5, 0.5) + else + self.grip:SetGripColor(0.35, 0.35, 0.35) + end + end + end) + + return item + end + + -- ========== QUICK ADD BUTTONS ========== + local buttonRow = CreateFrame("Frame", nil, container) + buttonRow:SetSize(460, 28) + buttonRow:SetPoint("TOPLEFT", leftBg, "BOTTOMLEFT", 0, -8) + + local function CreateQuickAddButton(text, role, color, xOffset) + local btn = CreateFrame("Button", nil, buttonRow, "BackdropTemplate") + btn:SetPoint("LEFT", xOffset, 0) + -- Persistent role colour via the shared tinted variant — the colour IS the + -- button's identity, so it stays on at rest and brightens on hover. + GUI:StyleButton(btn, { + width = 68, height = 24, + tinted = true, + accent = { r = color[1], g = color[2], b = color[3] }, + text = text, + }) + btn:SetScript("OnClick", function() + local players = getPlayersFunc() + for _, player in ipairs(currentRoster) do + if role == "ALL" or player.role == role then + if not IsPlayerHighlighted(player.fullName) then + table.insert(players, player.fullName) + end + end + end + setPlayersFunc(players) + if onChangeCallback then onChangeCallback() end + container:Refresh() + end) + return btn + end + + CreateQuickAddButton("+ " .. L["Tanks"], "TANK", ROLE_COLORS.TANK, 0) + CreateQuickAddButton("+ " .. L["Healers"], "HEALER", ROLE_COLORS.HEALER, 72) + CreateQuickAddButton("+ " .. L["DPS"], "DAMAGER", ROLE_COLORS.DAMAGER, 144) + CreateQuickAddButton("+ " .. L["All"], "ALL", {0.6, 0.6, 0.6}, 216) + + -- Clear All button (right side) — persistent red via the tinted variant. + local clearBtn = CreateFrame("Button", nil, buttonRow, "BackdropTemplate") + clearBtn:SetPoint("RIGHT", 0, 0) + GUI:StyleButton(clearBtn, { + width = 68, height = 24, + tinted = true, + accent = { r = 0.85, g = 0.35, b = 0.35 }, + text = L["Clear All"], + }) + clearBtn:SetScript("OnClick", function() + setPlayersFunc({}) + if onChangeCallback then onChangeCallback() end + container:Refresh() + end) + + -- Remove Offline button (next to Clear All) — persistent gold via tinted. + local removeOfflineBtn = CreateFrame("Button", nil, buttonRow, "BackdropTemplate") + removeOfflineBtn:SetPoint("RIGHT", clearBtn, "LEFT", -6, 0) + GUI:StyleButton(removeOfflineBtn, { + width = 90, height = 24, + tinted = true, + accent = { r = 0.85, g = 0.65, b = 0.35 }, + text = L["Remove Offline"], + }) + removeOfflineBtn:SetScript("OnClick", function() + local players = getPlayersFunc() + local newPlayers = {} + + -- Keep only players that are in the current roster + for _, fullName in ipairs(players) do + local inGroup = false + for _, p in ipairs(currentRoster) do + if p.fullName == fullName or p.name == fullName then + inGroup = true + break + end + end + if inGroup then + table.insert(newPlayers, fullName) + end + end + + setPlayersFunc(newPlayers) + if onChangeCallback then onChangeCallback() end + container:Refresh() + end) + + -- ========== MANUAL PLAYER ENTRY ========== + local themeColor = GetThemeColor() + local manualHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") + manualHeader:SetPoint("TOPLEFT", buttonRow, "BOTTOMLEFT", 0, -12) + manualHeader:SetText(L["Add Offline Player"]) + manualHeader:SetTextColor(themeColor.r, themeColor.g, themeColor.b) + + local manualHelp = container:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") + manualHelp:SetPoint("TOPLEFT", manualHeader, "BOTTOMLEFT", 0, -2) + manualHelp:SetText(L["Pre-configure players before they join the group"]) + manualHelp:SetTextColor(0.45, 0.45, 0.45) + + local manualInput = CreateFrame("EditBox", nil, container, "BackdropTemplate") + manualInput:SetPoint("TOPLEFT", manualHelp, "BOTTOMLEFT", 0, -6) + manualInput:SetSize(380, 24) + GUI:StyleEditBox(manualInput, { skipFont = true }) + manualInput:SetFontObject(DFFontHighlight) + manualInput:SetTextInsets(8, 8, 0, 0) + manualInput:SetAutoFocus(false) + manualInput:SetMaxLetters(50) + + manualInput:SetScript("OnEscapePressed", function(self) + self:ClearFocus() + end) + + manualInput:SetScript("OnEnterPressed", function(self) + local text = self:GetText():trim() + if text ~= "" then + -- Add realm if not present + if not text:find("-") then + text = text .. "-" .. GetRealmName() + end + AddPlayer(text) + self:SetText("") + container:Refresh() + end + self:ClearFocus() + end) + + local addManualBtn = CreateFrame("Button", nil, container, "BackdropTemplate") + addManualBtn:SetPoint("LEFT", manualInput, "RIGHT", 6, 0) + GUI:StyleButton(addManualBtn, { + width = 54, height = 24, + tinted = true, + text = L["Add"], + }) + addManualBtn:SetScript("OnClick", function() + local text = manualInput:GetText():trim() + if text ~= "" then + if not text:find("-") then + text = text .. "-" .. GetRealmName() + end + AddPlayer(text) + manualInput:SetText("") + container:Refresh() + end + end) + + -- ========== REFRESH FUNCTION ========== + function container:Refresh() + -- Get current roster + currentRoster = GetGroupRoster() + local players = getPlayersFunc() + + -- Clear existing items + for _, item in ipairs(rosterItems) do + item:Hide() + item:SetParent(nil) + end + wipe(rosterItems) + + for _, item in ipairs(highlightItems) do + item:Hide() + item:SetParent(nil) + end + wipe(highlightItems) + + -- Update counts + leftCount:SetText("(" .. #currentRoster .. ")") + rightCount:SetText("(" .. #players .. ")") + + -- Build left column (roster) + for i, playerData in ipairs(currentRoster) do + local item = CreateRosterItem(playerData, i) + table.insert(rosterItems, item) + end + leftContent:SetHeight(math.max(1, #currentRoster * ITEM_HEIGHT)) + + -- Build right column (highlighted) + for i, fullName in ipairs(players) do + local item = CreateHighlightItem(fullName, i, #players) + table.insert(highlightItems, item) + end + rightContent:SetHeight(math.max(1, #players * ITEM_HEIGHT)) + + -- Show hint if empty + if #players == 0 then + if not container.emptyHint then + container.emptyHint = rightContent:CreateFontString(nil, "OVERLAY", "DFFontNormal") + container.emptyHint:SetPoint("CENTER", rightBg, "CENTER", 0, 0) + container.emptyHint:SetText(L["Add players from the roster\nor use quick add buttons"]) + container.emptyHint:SetTextColor(0.35, 0.35, 0.35) + container.emptyHint:SetJustifyH("CENTER") + end + container.emptyHint:Show() + elseif container.emptyHint then + container.emptyHint:Hide() + end + end + + -- Register for roster updates + container:RegisterEvent("GROUP_ROSTER_UPDATE") + container:RegisterEvent("PLAYER_ENTERING_WORLD") + container:SetScript("OnEvent", function(self, event) + self:Refresh() + end) + + -- Initial refresh + container:Refresh() + + return container +end + +-- Gradient Preview Bar +function GUI:CreateGradientBar(parent, width, height, db, prefix) + prefix = prefix or "healthColor" + local f = CreateFrame("Frame", nil, parent, "BackdropTemplate") + f:SetSize(width or 360, height or 24) + CreateElementBackdrop(f) + + local lbl = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmallOutline") + lbl:SetPoint("LEFT", f, "LEFT", 8, 0) + lbl:SetText("0%") + lbl:SetTextColor(1, 1, 1, 1) + + local lbl2 = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmallOutline") + lbl2:SetPoint("RIGHT", f, "RIGHT", -8, 0) + lbl2:SetText("100%") + lbl2:SetTextColor(1, 1, 1, 1) + + f.TexPool = {} + + f.UpdatePreview = function() + if not db then return end + + for _, tex in ipairs(f.TexPool) do tex:Hide() end + + local _, pClass = UnitClass("player") + local classCol = DF:GetClassColor(pClass) + + local function GetC(stage) + if db[prefix .. stage .. "UseClass"] then + return CreateColor(classCol.r, classCol.g, classCol.b, 1) + end + local c = db[prefix .. stage] + if not c or not c.r then return CreateColor(1, 1, 1, 1) end + return CreateColor(c.r, c.g, c.b, 1) + end + + local lCol = GetC("Low") + local mCol = GetC("Medium") + local hCol = GetC("High") + + local lowW = math.max(1, math.floor(db[prefix .. "LowWeight"] or 1)) + local medW = math.max(1, math.floor(db[prefix .. "MediumWeight"] or 1)) + local highW = math.max(1, math.floor(db[prefix .. "HighWeight"] or 1)) + + local points = {} + for i = 1, lowW do table.insert(points, lCol) end + for i = 1, medW do table.insert(points, mCol) end + for i = 1, highW do table.insert(points, hCol) end + + if #points < 2 then points = {lCol, hCol} end + + local numSegments = #points - 1 + local segWidth = (f:GetWidth() - 4) / numSegments + + for i = 1, numSegments do + local tex = f.TexPool[i] + if not tex then + tex = f:CreateTexture(nil, "ARTWORK") + table.insert(f.TexPool, tex) + end + + tex:Show() + tex:ClearAllPoints() + tex:SetPoint("LEFT", f, "LEFT", 2 + (i - 1) * segWidth, 0) + tex:SetSize(segWidth, f:GetHeight() - 4) + + local c1 = points[i] + local c2 = points[i + 1] + + tex:SetColorTexture(1, 1, 1, 1) + tex:SetGradient("HORIZONTAL", c1, c2) + end + end + + f:SetScript("OnShow", f.UpdatePreview) + f.UpdatePreview() + return f +end + +-- ========================================================================= +-- SELECTABLE LIST WIDGET +-- Scrollable list of selectable items with hover highlight and accent +-- selection bar. Used by the Wizard Builder for wizard/step lists. +-- ========================================================================= + +-- ========================================================================= +-- SEARCHABLE DROPDOWN WIDGET +-- Dropdown with a search/filter box. Used for the DB key picker (800+ keys) +-- and any large option set. Groups items by category headers. +-- ========================================================================= + +-- ========================================================================= +-- KEY-VALUE EDITOR WIDGET +-- Editable list of key=value rows for the wizard builder settings map. +-- Each row: [Searchable Key Dropdown] = [Value Input] [X Delete] +-- ========================================================================= + +-- ========================================================================= +-- BRANCH EDITOR WIDGET +-- Visual editor for conditional wizard branching rules. +-- Each row: IF [step] [operator] [value] → [goto step] [X] +-- Plus: ELSE → [fallback step] +-- ========================================================================= + +-- ========================================================================= +-- MAIN GUI CREATION +-- ========================================================================= + +function DF:ToggleGUI() + if DF.GUIFrame and DF.GUIFrame:IsShown() then + DF.GUIFrame:Hide() + else + if not DF.GUIFrame then + DF:CreateGUI() + end + + -- Auto-detect mode based on current group status + -- ARENA FIX: Arena returns IsInRaid()=true but uses party-style layout/settings. + -- Check for arena first so the settings UI shows party settings, not raid. + if DF.IsInArena and DF:IsInArena() then + GUI.SelectedMode = "party" + elseif IsInRaid() then + GUI.SelectedMode = "raid" + else + GUI.SelectedMode = "party" + end + + -- Update theme colors to match selected mode + if GUI.UpdateThemeColors then + GUI.UpdateThemeColors() + end + + -- Show correct content for the selected mode + if GUI.ShowNormalContent then + GUI:ShowNormalContent() + end + + -- Refresh editing UI state (re-enables tabs that were disabled when closed during editing) + local AutoProfilesUI = DF.AutoProfilesUI + if AutoProfilesUI and AutoProfilesUI.RefreshEditingUI then + AutoProfilesUI:RefreshEditingUI() + end + + -- Refresh override stars (shows if a runtime profile is active) + if AutoProfilesUI and AutoProfilesUI.RefreshTabOverrideStars then + AutoProfilesUI:RefreshTabOverrideStars() + end + + DF.GUIFrame:Show() + GUI:RefreshCurrentPage() + + -- Auto-show changelog on first open after update + if DandersFramesDB_v2 and DandersFramesDB_v2.lastSeenVersion ~= DF.VERSION then + DandersFramesDB_v2.lastSeenVersion = DF.VERSION + if GUI.changelogOverlay and GUI.changelogArea then + GUI.changelogArea:SetText(GUI.FormatChangelog(DF.CHANGELOG_TEXT)) + GUI.changelogOverlay:Show() + end + end + end +end + \ No newline at end of file diff --git a/GUI/GUI.lua b/GUI/GUI.lua index 953c35c5..f720926e 100644 --- a/GUI/GUI.lua +++ b/GUI/GUI.lua @@ -1,13342 +1,1964 @@ -local addonName, DF = ... -local GUI = {} -DF.GUI = GUI -local L = DF.L - --- Mutable module state. --- These four were file-scope locals, and two of them are read and written more --- than 6,000 lines apart. That is harmless in one file and silently broken the --- moment the file is split: a `local` re-declared in the second half becomes an --- independent variable, so the two halves stop sharing state with no error. --- Holding them on one table keeps a future split honest -- each part reads the --- same table via `local S = GUI._state`. Read-only aliases (the C_* colours, L, --- GUI itself) do not need this: re-declaring those yields the same value. --- Private by convention -- nothing outside this file should touch GUI._state. -local S = {} -GUI._state = S - --- Shared private helpers. --- A `local function` is invisible to any other file, so a helper used thousands --- of lines from its declaration pins this file together: it cannot be split --- while a later section still calls it. Publishing the wide-reaching ones here --- lets a sibling file re-declare `local X = GUI._priv.X` -- the same function --- object, so no call site changes and behaviour is identical. --- Only helpers spanning >1000 lines are listed; narrow ones stay private. --- Private by convention -- nothing outside GUI/ should touch GUI._priv. -local P = {} -GUI._priv = P - --- ========================================================================= --- MODERN UI CONSTANTS & STYLING (Matching Original v2.3.8) --- ========================================================================= - -local C_BACKGROUND = {r = 0.08, g = 0.08, b = 0.08, a = 0.95} -- Dark charcoal -local C_PANEL = {r = 0.12, g = 0.12, b = 0.12, a = 1} -- Slightly lighter -local C_ELEMENT = {r = 0.18, g = 0.18, b = 0.18, a = 1} -- Element backgrounds -local C_BORDER = {r = 0.25, g = 0.25, b = 0.25, a = 1} -- Subtle borders -local C_ACCENT = {r = 0.45, g = 0.45, b = 0.95, a = 1} -- Party Purple-Blue -local C_RAID = {r = 1.0, g = 0.5, b = 0.2, a = 1} -- Raid Orange -local C_HOVER = {r = 0.22, g = 0.22, b = 0.22, a = 1} -local C_TEXT = {r = 0.9, g = 0.9, b = 0.9, a = 1} -local C_TEXT_DIM = {r = 0.6, g = 0.6, b = 0.6, a = 1} -local C_WARNING = {r = 0.95, g = 0.35, b = 0.35, a = 1} -- Soft red: behaviour-change / caution notes - --- Exported palette: other files should theme against these shared tables instead --- of re-declaring private copies or hardcoding the raw numbers. These are the --- SAME table references as the locals above. For the mode-aware accent, use --- GUI.GetThemeColor() (returns party purple or raid orange). -GUI.Colors = { - background = C_BACKGROUND, - panel = C_PANEL, - element = C_ELEMENT, - border = C_BORDER, - accent = C_ACCENT, -- party purple - raid = C_RAID, -- raid orange - hover = C_HOVER, - text = C_TEXT, - textDim = C_TEXT_DIM, - warning = C_WARNING, -- soft red for behaviour-change / caution notes -} - --- Dialog chrome. Popup.lua is a standalone dialog rather than a settings page and --- wanted the same handful of extras on top of the shared neutrals — so it had --- grown a private copy of the WHOLE palette (11 hardcoded colours), matching --- today only by luck. (WizardBuilder.lua was the other such dialog, since --- deleted as dead code — hence "three separate copies" in the note below.) --- One owner: the neutrals below are the SAME tables as GUI.Colors, so they --- theme-track in lockstep, and only what genuinely differs is declared here. --- Read-only by convention — these tables are shared, so nothing may mutate them. -GUI.DialogColors = { - -- Dialogs use the SAME ground as the pages. This was a bespoke 0.97 in three - -- separate copies, a shade denser than the pages' 0.95 for no reason anyone - -- could point at; consolidating the copies made the difference visible and - -- it went. Chrome now reads identically whether it's a page or a dialog. - background = C_BACKGROUND, - panel = C_PANEL, - element = C_ELEMENT, - border = C_BORDER, - accent = C_ACCENT, -- fallback only; live dialogs read GetThemeColor() - hover = C_HOVER, - text = C_TEXT, - textDim = C_TEXT_DIM, - selected = {r = 0.28, g = 0.28, b = 0.45, a = 1}, - -- Status pair for dialog content (valid/invalid rows, ok/error dots). - green = {r = 0.2, g = 0.9, b = 0.2}, - red = {r = 0.9, g = 0.25, b = 0.25}, - orange = {r = 0.85, g = 0.55, b = 0.1}, -} - --- Canonical row heights (the "airier" scale). A fixed-height widget factory stamps its own slot --- height onto the widget (widget.preferredHeight + widget.fixedRowHeight), so the layout uses THAT --- and a call-site number can't make the same widget type render at a different height on a different --- page. New callers can omit the height entirely; legacy call-site numbers on fixed widgets are --- ignored (harmless, strippable later). Variable widgets (labels, headers, spacers) are NOT stamped --- and keep whatever height they are given. One place to retune the whole GUI's vertical rhythm. --- THE vertical rhythm of the whole GUI. --- --- These are SLOT heights, not gaps -- LayoutChildren stacks rows flush --- (y = y - height), so the gap the eye sees between two rows is: --- --- (slot - content) of the row above + (content's top inset) of the row below --- --- which means a row whose content is short inside a tall slot silently gets a big --- gap. That is how the GUI ended up with a 4x spread. /df debug gapcheck measured it --- across four pages (267 rows), and the content heights came back IDENTICAL on --- every page, so the slots can be derived rather than guessed: --- --- kind content old slot old gap new slot new gap --- slider 32.0 55 23.0 46 14 --- dropdown 39.8 55 15.2 54 14 --- editbox 39.0 55 16.0 53 14 --- colorpicker 23.9 30 6.1 38 14 --- checkbox 18.2 30 11.8* 35 14* --- header 11.9 34 / 40 11.0 / 17.0 37 14 --- --- So: ONE gap, and every slot is content + RowGap. 14 is not arbitrary -- it is --- what the dropdown rows already had (15.2), the one spacing Krathe confirmed --- reads correctly. Sliders lose 9px of slack, colour pickers gain 8. --- --- * the checkbox's content sits 2.9px below its slot top, so a row landing ON a --- checkbox reads 14 + 2.9. Zeroing that would mean re-anchoring the checkbox --- art itself, which moves the tick 3px for 3px -- not worth it. --- --- A header keeps its 11.1px top inset, so the gap ABOVE a header is 14 + 11.1. --- That is deliberate: a section title wants air above it and to sit close to --- what it labels. -GUI.RowGap = 14 - --- ...with ONE exception: a RUN of the same COMPACT row type closes up. --- --- A uniform gap everywhere is right between DIFFERENT kinds -- that is the --- boundary the eye uses to tell one control apart from the next. But eight --- checkboxes in a column are one list, not eight things, and 14 between each of --- them reads as a stack of unrelated rows. So consecutive rows of the same --- compact kind get RowGapTight, and the first row of a different kind after them --- gets the full RowGap back. Same spacing between TYPES, tighter within a type. --- --- Compact means the label sits INLINE with the control (a checkbox's text is --- beside its tick). Slider, dropdown and edit box are deliberately NOT compact: --- their label sits ABOVE the control, so tightening the gap there would push the --- next row's label into the control above it -- the same reasoning already --- recorded on labelPad, and the reason a stack of sliders needs real air even --- though a stack of checkboxes does not. -GUI.RowGapTight = 8 -GUI.RowCompact = { - checkbox = true, - toggle = true, - colorpicker = true, -} - --- PAGE-level spacing, a different axis from the row rhythm above: AddSpace --- inserts a spacer into the page's COLUMN flow, between groups, not between rows --- inside one. Groups already carry a 10px margin of their own, so these stack ON --- TOP of that -- a `section` break reads as 20 between two groups, a `block` as --- 30. --- --- The audit found 58 AddSpace calls passing 9 different numbers, which looked --- worse than it was: classified by INTENT rather than by value, two idioms cover --- 42 of them and both were already internally consistent -- --- --- section break (after `currentSection = nil`, or a bare gap after a group) --- 19/19 at 10, plus 7 more following an Add() --- before the See-Also links at the foot of a page --- 15/16 at 20, one stray 15 --- --- The real inconsistency was three files each picking their own number for the --- SAME intent (NicknamesPage used 12 throughout), not 9 competing rhythms. -GUI.Space = { - section = 10, -- between logical sections in a page column - block = 20, -- before a distinct trailing block (the See-Also links) - footer = 12, -- below the See-Also bar when it is parked at the viewport bottom -} - -GUI.RowHeight = { - checkbox = 35, -- 2.9 top inset + 18.2 content + RowGap - slider = 46, -- 32.0 content + RowGap (was 55: a slot sized for the dropdown) - dropdown = 54, -- 39.8 content + RowGap - colorpicker = 38, -- 23.9 content + RowGap - editbox = 53, -- 39.0 content + RowGap (box at -15, h24) - toggle = 35, -- two-state switch; same content as a checkbox, same row - -- Labels are VARIABLE height (they wrap), so they have no fixed row — but they do - -- have fixed CHROME, which CreateLabel adds to the measured text height: the 5px top - -- inset its FontString sits at, plus the gap below. That gap IS the whole visible - -- space to the next row — LayoutChildren stacks rows flush (y = y - height) and a - -- labelled control (dropdown/slider/editbox) puts its own label at TOPLEFT 0,0 — so a - -- smaller pad reads as a blurb crowding the control it describes. - labelPad = 5 + GUI.RowGap, - -- EVERY section header, collapsible or not. CreateHeader's container is 25 - -- tall with its text pinned to the BOTTOM, so its 11.1px of internal padding - -- all sits ABOVE the text and this slot minus 25 is the entire visible gap - -- below it -- at exactly 25 there is none. - -- - -- The old split was never designed: collapsible groups were handed 25 (no - -- gap) and plain ones 40, across both files, for the same construct. Rather - -- than sweep ~200 call sites, CreateHeader now marks itself fixedRowHeight, - -- so ResolveRowHeight IGNORES the literal a call site passes and every header - -- lands here. That is the same rule the other factory rows already follow. - sectionHeader = 11.1 + 11.9 + GUI.RowGap, -- top inset + text + the gap -} - --- Resolve the layout slot height for a widget being added to a group/page. Fixed-height widgets --- own their height (drift-proof); everything else uses the height it was handed, then the widget's --- own preferred height, then a sane default. -local function ResolveRowHeight(widget, height) - if widget and widget.fixedRowHeight and widget.preferredHeight then - return widget.preferredHeight - end - return height or (widget and widget.preferredHeight) or 55 -end -GUI.ResolveRowHeight = ResolveRowHeight - --- Sync a widget's slot height into its host SettingsGroup and re-flow. Used by any --- widget that only learns its true height AFTER construction (a measured label, an --- info banner): update the group's stored entry, re-lay out the group, then bubble to --- the page so sibling groups in the same column re-anchor to the group's new bottom. --- Without the bubble a grown group's backdrop overshoots the next group's anchor and --- renders as an empty rectangle of backdrop above it. -function GUI:RelayoutHost(widget, slotHeight) - if not widget then return end - local g = widget.settingsGroup - -- RETIRED widgets must not re-flow anything. A measured label arms a next-frame - -- converge; if the page rebuilds first (any Refresh — e.g. flipping the Colours - -- page's Seconds/Percent tabs), that timer still fires against the PREVIOUS build. - -- Its group is by then parented to the trash frame with its anchors cleared, so - -- re-laying it out sizes children off a dead frame, and the parent walk below still - -- reaches the LIVE page and makes it re-lay out mid-flight. IsShown() does not catch - -- this — a frame keeps its own shown flag when an ancestor is hidden — so test the - -- ancestry instead. - local trash = GUI._trashFrame - if trash then - local p = (g or widget) - while p do - if p == trash then return end - p = p:GetParent() - end - end - if g and g.LayoutChildren then - for _, entry in ipairs(g.groupChildren or {}) do - if entry.widget == widget then - entry.height = slotHeight - break - end - end - g:LayoutChildren() - end - local p = (g or widget):GetParent() - while p do - if type(p.RefreshStates) == "function" and p.children then - p:RefreshStates() - return - end - p = p:GetParent() - end -end - --- "/df debug guiwidth" — width ground truth for the page on screen: every top-level child and --- every settings-group child with its live width, flagging any that is non-positive or --- narrower than its group allows. --- Use it to TELL APART the two causes of truncated label text, which look identical: --- * a real width fault -> frames show up flagged here; --- * a stale FontString wrap -> every width reads correct and the count is 0, yet the --- text is visibly ellipsised (and scrolling the window snaps it back, because that --- re-renders the string). That is CreateLabel's Reflow case, not a sizing bug. --- Recorded because the second one cost two wrong fixes before this dump ruled out the first. -function GUI:DebugDumpWidths() - local page = GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] - if not page or not page.children then - DF:Say("GUI width", "no built page on screen", "WARN") - return - end - local content = GUI.contentFrame and GUI.contentFrame:GetWidth() or -1 - local o = DF:Out("GUI Width", ("page '%s'"):format(GUI.CurrentPageName)) - o:Section("Widths") - o:Field("content frame", ("%.0f"):format(content), content > 0 and "GOOD" or "BAD") - o:Field("scroll child", ("%.0f"):format(page.child and page.child:GetWidth() or -1), "NEUTRAL") - - o:Section("Widgets") - local bad = 0 - local function flag(w, limit) - if not w or w <= 0 then bad = bad + 1; return "|cffff4444 <== NON-POSITIVE|r" end - if limit and w < limit - 1 then bad = bad + 1; return "|cffffcc00 <== NARROW|r" end - return "" - end - for i, widget in ipairs(page.children) do - if widget.isSettingsGroup then - local gw = widget:GetWidth() or 0 - local inner = gw - (widget.padding or 10) * 2 - print((" [%d] group w=%.0f inner=%.0f col=%s shown=%s%s") - :format(i, gw, inner, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(gw))) - for j, entry in ipairs(widget.groupChildren or {}) do - local c = entry.widget - local cw = c and c:GetWidth() or 0 - print((" (%d) w=%.0f h=%.0f shown=%s%s") - :format(j, cw, entry.height or -1, tostring(c and c:IsShown()), flag(cw, inner))) - end - else - local w = widget:GetWidth() or 0 - print((" [%d] widget w=%.0f col=%s shown=%s%s") - :format(i, w, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(w))) - end - end - o:Section("Result") - o:Field("suspect frames", bad, bad > 0 and "WARN" or "GOOD") - o:Siblings("guiwidth") -end - -DF.SectionRegistry = DF.SectionRegistry or {} - - --- Track selected mode -GUI.SelectedMode = "party" - --- Registry of tabs that should show a "New" badge until opened. --- Add tab IDs here for new features; the badge auto-hides once viewed. --- Reset each release cycle to the tabs that are new since the last stable --- (prior entries are persisted as seen and would otherwise show stale badges). -GUI.NewTabs = { - ["text_designer"] = true, - ["general_nicknames"] = true, -} - --- Registry of section headers (inside a tab) that should show a "New" badge --- until the user visits the tab and then navigates away. Keyed by --- "." so entries are unambiguous across tabs. --- The badge is created by GUI:AddSectionNewBadge and cleared by SelectTab --- when the user leaves the owning tab (persisted via seenSections). -GUI.NewSections = { -} - --- Live-tracked badges pending a "seen" mark, keyed by tabName → { key = badge }. --- Populated by AddSectionNewBadge, drained by SelectTab on tab leave. -GUI.pendingSectionBadges = {} - --- Add a gold "New" badge to the right of a section header's text. Returns the --- badge FontString, or nil if the section isn't registered in NewSections or --- has already been marked seen. The badge clears (and is persisted as seen) --- the next time the user navigates away from `tabName`. -function GUI:AddSectionNewBadge(widget, tabName, sectionId) - -- Anchor to whichever label FontString the widget exposes: - -- * CreateHeader containers use `.text` - -- * CreateDropdown containers use `.label` - local anchor = widget and (widget.text or widget.label) - if not anchor or not tabName or not sectionId then return end - local key = tabName .. "." .. sectionId - if not GUI.NewSections[key] then return end - - local seen = DandersFramesDB_v2 and DandersFramesDB_v2.seenSections - and DandersFramesDB_v2.seenSections[key] - if seen then return end - - local badge = widget:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - badge:SetPoint("LEFT", anchor, "RIGHT", 6, 0) - badge:SetText(L["New"]) - badge:SetTextColor(1, 0.82, 0) - - GUI.pendingSectionBadges[tabName] = GUI.pendingSectionBadges[tabName] or {} - GUI.pendingSectionBadges[tabName][key] = badge - return badge -end - --- Pages that remain fully accessible regardless of whether party or raid --- mode is disabled via General settings. All other mode-specific tabs --- are greyed out and non-interactive when viewing a disabled mode. --- Auto Layouts is intentionally NOT whitelisted: it edits per-profile --- settings that would have no effect if all frames are disabled. -GUI.AlwaysAccessiblePages = { - ["general_settings"] = true, -- the toggles themselves - ["profiles_manage"] = true, - ["profiles_importexport"] = true, - ["debug_console"] = true, - ["indicators_targetedlist"] = true, - ["indicators_personal_targeted"] = true, -} - --- Returns true if the given tab should be disabled for the currently --- selected mode (i.e. the tab is mode-specific and that mode is off). -function GUI:IsTabDisabledForCurrentMode(tabName) - if not tabName then return false end - if GUI.AlwaysAccessiblePages[tabName] then return false end - if GUI.SelectedMode == "party" and DF.db and DF.db.partyEnabled == false then return true end - if GUI.SelectedMode == "raid" and DF.db and DF.db.raidEnabled == false then return true end - return false -end - --- Walk all registered tabs and update their .disabled flag + visuals --- based on the current mode and enable flags. Call after mode switches. -function GUI:UpdateTabAvailability() - if not GUI.Tabs then return end - for name, btn in pairs(GUI.Tabs) do - local disabled = GUI:IsTabDisabledForCurrentMode(name) - btn.disabled = disabled - if btn.Text then - if disabled then - btn.Text:SetTextColor(0.45, 0.45, 0.45) - else - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - if disabled and not btn.isActive then - btn:SetBackdropColor(0, 0, 0, 0) - end - end - - -- Refresh the sidebar so party-only tabs (e.g. Visibility) hide/show for - -- the current mode. - if GUI.UpdateTabLayout then GUI:UpdateTabLayout() end - - -- If the active tab just became hidden (party-only while in raid), move to a - -- safe always-present tab so the user isn't left on a hidden/empty page. - if not GUI._redirectingTab and GUI.SelectedMode == "raid" and GUI.CurrentPageName then - local cur = GUI.Tabs[GUI.CurrentPageName] - if cur and cur.partyOnly and GUI.SelectTab then - GUI._redirectingTab = true - GUI.SelectTab("general_settings") - GUI._redirectingTab = false - end - end -end - --- Track currently open dropdown menu (only one can be open at a time) -S.currentOpenDropdown = nil - --- Close any currently open dropdown -local function CloseOpenDropdown() - if S.currentOpenDropdown and S.currentOpenDropdown:IsShown() then - S.currentOpenDropdown:Hide() - end - S.currentOpenDropdown = nil -end -P.CloseOpenDropdown = CloseOpenDropdown - --- Helper to get current theme color --- The theme colour for an EXPLICIT mode. Use this for any surface that belongs to --- a mode rather than to whatever page the options window happens to be showing -- --- movers, pinned containers -- so a raid surface stays orange while the window is --- on a party page. GetThemeColor() below is the follow-the-window variant. -local function GetThemeColorFor(isRaid) - if isRaid then return C_RAID else return C_ACCENT end -end -GUI.GetThemeColorFor = GetThemeColorFor - -local function GetThemeColor() - return GetThemeColorFor(GUI.SelectedMode == "raid") -end -GUI.GetThemeColor = GetThemeColor - --- Physical pixels per UI unit, measured from the frame's OWN effective scale. --- The GUI window carries a user scale (guiScale) on top of UIParent's and is --- freely resizable, so this is almost never 1 and cannot be read from the --- addon-wide DF:GetPixelScale (which is relative to UIParent). -local function PixelsPerUnit(frame) - local eff = frame and frame.GetEffectiveScale and frame:GetEffectiveScale() - local _, physH = GetPhysicalScreenSize() - if not (eff and eff > 0 and physH and physH > 0) then return nil end - return eff * physH / 768 -end -P.PixelsPerUnit = PixelsPerUnit - --- ⚠ THERE IS NO RUNTIME GEOMETRY CORRECTION, AND THERE SHOULD NOT BE ONE. --- --- This GUI used to carry a registry that nudged every bordered box onto the --- pixel grid after the layout had placed it. It is gone, and the reason is --- structural rather than a bug in the implementation: Lua-side snapping can only --- correct a frame at REST, and the symptom it was chasing -- a thin border going --- soft -- is at its worst while the content is MOVING, during a scroll. --- Correcting mid-scroll does not fix that, it adds a half-pixel jump on top of --- it, which reads as flicker. It also cost us the button-row drift, where an --- unordered sweep corrected chained anchors in a different order each pass. --- --- Three things replaced it, and between them they are enough: --- * the LAYOUT picks whole-pixel numbers in the first place (SnapLen below, at --- the point an offset, width or height is chosen). No runtime cost, nothing --- to drift, nothing to flicker -- it just picks better numbers. --- * the CLIP SURFACES are snapped (page viewport insets, the content panel, --- the nav chain). Those were real bugs: a box clipped by a fractional edge --- loses part of its border no matter how well the box itself is placed. --- * the BORDER is drawn as two device pixels of our own texture rather than a --- one-unit backdrop edge, so it cannot land badly in the first place. See --- PIXEL BORDER further down -- that is the part that actually solved it. - --- Round a LENGTH or OFFSET (in UI units) to a whole number of device pixels at --- the scale `frame` is drawn at. This is the layout-side half of the pixel-grid --- work, and the half that was missing. --- --- A box lands on the grid only if the numbers it was GIVEN were whole pixels. --- They usually are not: a group's inner width is (its width - 2 * padding), and --- its padding is 10 UI units, which is a whole number of device pixels only when --- the GUI is at exactly 1:1 scale. Everywhere else the box's right and bottom --- edges fall mid-pixel, and anything clipped by them loses part of itself. --- --- Snapping at the point the number is CHOSEN fixes that by construction, for --- everything a layout places, with no per-widget opt-in for anyone to forget. -local function SnapLen(frame, v) - if not v then return v end - local ppu = PixelsPerUnit(frame) - if not ppu then return v end - return math.floor(v * ppu + 0.5) / ppu -end -GUI.SnapLen = SnapLen - --- SnapLen rounds to the NEAREST device pixel, which can round a width DOWN and --- clip the text it was measured from. This rounds up instead: for anything whose --- length comes from a measurement (a button sized to its own label), the width --- has to be at least what was asked for, and on the grid. -local function SnapLenUp(frame, v) - if not v then return v end - local ppu = PixelsPerUnit(frame) - if not ppu then return v end - return math.ceil(v * ppu) / ppu -end -GUI.SnapLenUp = SnapLenUp - --- Round a HEIGHT to an EVEN number of device pixels (minimum two). --- --- Rows of controls are chained with centre-aligning anchors -- SetPoint("RIGHT", --- prev, "LEFT", gap, 0) aligns the two frames' vertical CENTRES, and the toolbar --- does the same with LEFT/RIGHT. A frame's centre is bottom + height/2, so if the --- height is an ODD number of device pixels the centre falls on a half pixel, and --- every frame chained off it inherits that half-pixel offset no matter how well --- its own edges are snapped. Even heights make the whole row land together. --- --- Used for control heights, which the factories set once at construction. Widths --- do not need this: nothing centre-anchors horizontally off a control. -local function SnapHeightEven(frame, v) - if not v then return v end - local ppu = PixelsPerUnit(frame) - if not ppu then return v end - return math.max(2, math.floor(v * ppu / 2 + 0.5) * 2) / ppu -end -GUI.SnapHeightEven = SnapHeightEven - --- ============================================================ --- Stamped once when this file loads, so it identifies THIS session (and therefore --- this build) for the pixelcheck and gapcheck captures. Declared HERE, above the --- first function that reads it: a local declared further down the file is not an --- upvalue for a function defined above it -- the read would silently resolve to a --- nil global and every capture would look like a new session. -local GAP_SESSION = date and date("%Y-%m-%d %H:%M:%S") or "?" - --- /df debug pixelcheck -- measure, don't guess --- --- The "top border of a box goes missing until you scroll" bug has now been --- diagnosed twice from reasoning about the layout and fixed twice, and it is --- still here. The two remaining explanations look IDENTICAL in a screenshot: --- --- (a) sub-pixel -- the box's top edge lands between two device rows, so the --- 1px line is filtered across both at half intensity; --- (b) clipping -- the box's top edge sits within a pixel of the ScrollFrame's --- own clip boundary, so the line is simply cut off. --- --- Scrolling "fixes" both, which is exactly why the screenshot can't separate --- them. This reports the numbers for the open page so the next change is aimed. --- Debug output: deliberately raw, like the other /df debug dumps. --- ============================================================ - --- Signed distance from `v` (UI units) to the nearest whole device pixel. -local function PixelOffsetOf(v, ppu) - if not v or not ppu then return nil end - local px = v * ppu - return px - math.floor(px + 0.5) -end - -local function DescribeFrame(f) - -- Prefer a human label so the output names the card, not "Frame". - for _, key in ipairs({ "label", "title", "titleText", "Text", "header" }) do - local o = f[key] - if type(o) == "table" and o.GetText then - local t = o:GetText() - if t and t ~= "" then return t end - end - end - if f.GetRegions then - for _, r in ipairs({ f:GetRegions() }) do - if r.GetObjectType and r:GetObjectType() == "FontString" then - local t = r:GetText() - if t and t ~= "" then return t end - end - end - end - return (f.GetObjectType and f:GetObjectType()) or "Frame" -end - --- Every SHOWN descendant carrying a border -- the only frames that can exhibit --- this bug (a fill-only surface has no edge to lose). --- --- Both mechanisms, deliberately. Testing edgeFile alone was right when that was --- the only way a border got drawn; now that most surfaces own their four --- textures instead, an edgeFile-only sweep would report a converted page as --- having no bordered frames at all and the probe would quietly stop being able --- to see the thing it exists to measure. -local function CollectBorderedFrames(root, out, depth) - if not root or depth > 10 then return out end - for _, child in ipairs({ root:GetChildren() }) do - if child:IsShown() then - local bd = child.GetBackdrop and child:GetBackdrop() - if (bd and bd.edgeFile) or child._pxBorder then out[#out + 1] = child end - CollectBorderedFrames(child, out, depth + 1) - end - end - return out -end - --- What sits ON the box's top border row. --- --- Geometry came back perfect on BOTH a broken and a working page (BOX 0/n, --- top+0.00, edge=1.00px, nothing near the clip edge), so the difference is not --- anything the box itself measures. The one thing that tracked the symptom was --- how far the first box sits from the top: 62.7px on a page that loses its --- border vs 76.8px on one that doesn't -- 14.1px apart, which at 1.4062 px/unit --- is exactly the 10-unit group padding. A box that starts hard against whatever --- is above it loses the line; one with a gap keeps it. That is the signature of --- the row above covering it, so: find any sibling whose rect spans the box's top --- border row, and report it with its frame level. -local function FindTopRowOverlaps(box) - local out = {} - local top, bL, bR = box:GetTop(), box:GetLeft(), box:GetRight() - local parent = box:GetParent() - if not (top and bL and bR and parent) then return out end - -- ~1.5 device px expressed in UI units: the border row plus a hair. - local band = 1.5 / (PixelsPerUnit(box) or 1) - for _, sib in ipairs({ parent:GetChildren() }) do - if sib ~= box and sib.IsShown and sib:IsShown() then - local sT, sB, sL, sR = sib:GetTop(), sib:GetBottom(), sib:GetLeft(), sib:GetRight() - if sT and sB and sL and sR - and sB <= top + band and sT >= top - band -- spans the border row - and sR > bL and sL < bR then -- and overlaps horizontally - out[#out + 1] = ("%s(lvl%d)"):format( - DescribeFrame(sib):sub(1, 16), sib:GetFrameLevel() or 0) - end - end - end - return out -end - -function GUI.PixelCheck() - local page, pageName - for name, p in pairs(GUI.Pages or {}) do - if p.IsShown and p:IsShown() then page, pageName = p, name; break end - end - if not page then - DF:Say("Pixel check", "no settings page is open", "WARN") - return - end - - local ppu = PixelsPerUnit(page) - if not ppu then - DF:Say("Pixel check", "scale unresolved — is the window shown?", "WARN") - return - end - - local scroll = page.GetVerticalScroll and page:GetVerticalScroll() or 0 - local scrollOff = PixelOffsetOf(scroll, ppu) - local clipTop = page:GetTop() - - local o = DF:Out("Pixel Check", ("page %s"):format(tostring(pageName))) - o:Section("Scale") - o:Field("effective scale", ("%.4f"):format(page:GetEffectiveScale() or 0), "NEUTRAL") - o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") - - o:Section("Scroll") - -- Reported, not judged. This used to print a red NOT SNAPPED when the offset - -- was off-grid, back when the scroll offset was quantised and being off-grid - -- meant something had gone wrong. Nothing quantises it now -- a 2px border - -- draws the same ink at any offset -- so an off-grid figure here is the - -- normal state of a scrolled page, and flagging it as a fault sends the next - -- person reading this output after a bug that is not there. - o:Field("offset", ("%.3f (%.2f px off grid)"):format(scroll, scrollOff or 0), "NEUTRAL") - o:Line("Off-grid here is expected — nothing quantises the scroll offset.", "NEUTRAL") - - -- THE CLIP BOUNDARY ITSELF. Earlier runs measured each box's DISTANCE to this - -- edge but never whether the edge is on-grid. A ScrollFrame clips to its own - -- rect, so if that rect's top sits on a fractional device row the cut takes a - -- partial row off whatever is nearest it -- which is exactly the reported - -- pattern: pages whose boxes start at the top lose the border, pages with a - -- gap do not. The scroll child is included because content is positioned - -- against it, so its phase is what every box inherits. - local dPageTop = PixelOffsetOf(page:GetTop(), ppu) - local dPageBot = PixelOffsetOf(page:GetBottom(), ppu) - local dPageH = PixelOffsetOf(page:GetHeight(), ppu) - -- Flag EITHER edge. This used to test only the top, so it printed - -- "bot-0.38" on every run for weeks and never once marked it -- and an - -- unflagged number in a wall of numbers is an invisible one. The bottom - -- edge clips whatever rests against it just as hard as the top does, which - -- is the entire See-Also footer bug. - local badTop = dPageTop and math.abs(dPageTop) > 0.05 - local badBot = dPageBot and math.abs(dPageBot) > 0.05 - o:Section("Viewport", "the clip edge") - o:Field("offsets", ("top%+.2f bot%+.2f h%+.2f"):format(dPageTop or 0, dPageBot or 0, dPageH or 0), - (badTop or badBot) and "BAD" or "GOOD") - if badTop or badBot then - o:Line(("Clip edge OFF-GRID (%s) — it shaves a partial row off whatever rests against it."):format( - badTop and (badBot and "top and bottom" or "top") or "bottom"), "BAD") - end - local kid = page.child or (page.GetScrollChild and page:GetScrollChild()) - if kid then - local kppu = PixelsPerUnit(kid) or ppu - local kidBad = math.abs(PixelOffsetOf(kid:GetTop(), kppu) or 0) > 0.05 - o:Field("scroll child", ("top%+.2f w%+.2f"):format( - PixelOffsetOf(kid:GetTop(), kppu) or 0, PixelOffsetOf(kid:GetWidth(), kppu) or 0), - kidBad and "BAD" or "GOOD") - if kidBad then - o:Line("Content is positioned against this, so every box inherits its phase.", "BAD") - end - end - - -- "Is it the section BOXES or the controls inside them?" is the question the - -- first version of this could not answer -- it ranked worst-first and the top - -- of the list was all controls, so the groups never showed. Classify, and - -- report the boxes separately no matter where they rank. - local function KindOf(f) - if f.LayoutChildren then return "BOX" end -- CreateSettingsGroup - if f.slider then return "slider" end - return (f.GetObjectType and f:GetObjectType()) or "frame" - end - - local frames = CollectBorderedFrames(page.child or page, {}, 0) - local rows, offGrid, nearClip = {}, 0, 0 - local byKind = {} - for _, f in ipairs(frames) do - local top, bottom, h = f:GetTop(), f:GetBottom(), f:GetHeight() - local fppu = PixelsPerUnit(f) or ppu - local dTop = PixelOffsetOf(top, fppu) - local dBot = PixelOffsetOf(bottom, fppu) - local dH = PixelOffsetOf(h, fppu) - -- Distance from the scroll viewport's top clip edge, in device pixels. - local clipGap = (top and clipTop) and ((clipTop - top) * fppu) or nil - if dTop and math.abs(dTop) > 0.05 then offGrid = offGrid + 1 end - if clipGap and clipGap > -1.5 and clipGap < 1.5 then nearClip = nearClip + 1 end - -- Edge THICKNESS, which is the leg two earlier revisions of this probe - -- did not capture and the one that turned out to matter. A box can - -- measure a perfect 0.00 on every edge and still lose its border if the - -- edge is drawn a fractional number of device pixels wide: it bleeds - -- into the next row at partial intensity, and a settings-group edge is - -- only ~8% alpha over a 3% fill, so both halves can land under the - -- visibility floor. Alpha is reported alongside because it sets that - -- floor. - -- - -- A pixel border reports its own thickness instead: it is authored in - -- device pixels, so it is a whole number by construction and edgeFrac - -- is 0 -- which is exactly the point of it, and worth being able to SEE - -- next to a surface still on the old edge. - local bdInfo = f.GetBackdrop and f:GetBackdrop() - local edgeUnits = bdInfo and bdInfo.edgeSize or nil - -- f._pxDevicePx, not a recomputation from PX_BORDER_THICKNESS: that - -- constant is declared several hundred lines below this probe, so - -- naming it here would resolve to a nil GLOBAL, silently. Reading what - -- LayoutPixelBorder actually drew is both safer and more truthful. - local edgePx = edgeUnits and (edgeUnits * fppu) or f._pxDevicePx or nil - local edgeFrac = edgePx and (edgePx - math.floor(edgePx + 0.5)) or nil - -- NOT `local _,_,_,a = (f:GetBackdropBorderColor())` -- the parentheses - -- truncate a multi-return to ONE value, so alpha came back nil every time - -- and every row printed "a=n/a". Alpha is the whole point here: a - -- settings-group edge is ~8% over a 3% fill, so it has almost no margin. - local borderA - if f.GetBackdropBorderColor then - local _, _, _, a = f:GetBackdropBorderColor() - borderA = a - end - local kind = KindOf(f) - local bad = (dTop and math.abs(dTop) > 0.05) or false - local k = byKind[kind] - if not k then k = { n = 0, bad = 0 }; byKind[kind] = k end - k.n = k.n + 1 - if bad then k.bad = k.bad + 1 end - rows[#rows + 1] = { - label = DescribeFrame(f), kind = kind, bad = bad, - frame = f, level = f.GetFrameLevel and f:GetFrameLevel() or nil, - dTop = dTop or 0, dBot = dBot or 0, - dH = dH or 0, clipGap = clipGap, - edgePx = edgePx, edgeFrac = edgeFrac, alpha = borderA, - score = math.max(math.abs(dTop or 0), - (clipGap and math.abs(clipGap) < 1.5) and 1 or 0), - } - end - - table.sort(rows, function(a, b) return a.score > b.score end) - o:Section("Bordered frames", #rows) - o:Field("off-grid top", offGrid, offGrid > 0 and "BAD" or "GOOD") - o:Field("within 1px of the clip edge", nearClip, nearClip > 0 and "WARN" or "GOOD") - - -- Per-kind tally: this is the line that says whether fixing controls would - -- also fix the section boxes, or whether they are a separate problem. - local kindLine = {} - for kind, k in pairs(byKind) do - kindLine[#kindLine + 1] = ("%s %d/%d"):format(kind, k.bad, k.n) - end - table.sort(kindLine) - o:Field("by kind (bad/total)", table.concat(kindLine, " "), "NEUTRAL") - - local function emit(r) - local flag = "" - if r.bad then flag = " |cffff6060OFF-GRID|r" end - if r.clipGap and math.abs(r.clipGap) < 1.5 then flag = flag .. " |cffffaa00AT-CLIP|r" end - -- A fractional edge width is the failure a perfect 0.00 box can still have. - if r.edgeFrac and math.abs(r.edgeFrac) > 0.05 then flag = flag .. " |cffff6060SOFT-EDGE|r" end - if r.alpha and r.alpha < 0.15 then flag = flag .. " |cffffaa00FAINT|r" end - print((" [%s] %-22s top%+.2f bot%+.2f h%+.2f edge=%s a=%s clip=%s%s"):format( - r.kind:sub(1, 6), r.label:sub(1, 22), r.dTop, r.dBot, r.dH, - r.edgePx and ("%.2fpx"):format(r.edgePx) or "n/a", - r.alpha and ("%.2f"):format(r.alpha) or "n/a", - r.clipGap and ("%.1f"):format(r.clipGap) or "n/a", flag)) - end - - -- The section boxes ALWAYS get listed, however they rank -- they are the ones - -- you can actually see, and ranking buried them last time. - local boxes = 0 - for _, r in ipairs(rows) do if r.kind == "BOX" then boxes = boxes + 1 end end - if boxes > 0 then - o:Section("Section boxes", boxes .. " — the outlines around each section") - local shown = 0 - for _, r in ipairs(rows) do - if r.kind == "BOX" and shown < 10 then - emit(r) - -- Anything sitting ON this box's top border row is the prime - -- suspect now that the box's own geometry measures clean. - local over = r.frame and FindTopRowOverlaps(r.frame) or {} - if #over > 0 then - print((" |cffff6060^ COVERED BY:|r %s (box is lvl%s)") - :format(table.concat(over, ", "), tostring(r.level))) - end - shown = shown + 1 - end - end - else - o:Section("Section boxes") - o:Line("None found on this page.", "WARN") - end - - o:Section("Worst overall") - o:Line("topOff/botOff/heightOff are px from the grid; clip is px below the viewport top.", "NEUTRAL") - for i = 1, math.min(#rows, 10) do emit(rows[i]) end - print(" |cff808080Read: OFF-GRID = geometry. SOFT-EDGE = the edge is a fractional number of device px wide, so it bleeds into the next row -- a box can be a perfect 0.00 and still lose its border this way. FAINT = so little alpha that any split is invisible.|r") - print(" |cff808080Nothing is corrected at runtime any more. Every widget gets its whole-pixel numbers from its FACTORY at construction (nudging them afterwards is what made chained button rows drift), so an off-grid row after a scale change is expected and is not a bug. What still matters here is SOFT-EDGE and FAINT.|r") - - -- Persist, for the same reason gapcheck does: transcribing a screenful of - -- numbers out of the chat frame is not practical remotely, and every reading - -- of this symptom that came from eyeballing rather than the file has been - -- wrong. Same session stamp, so a capture never mixes two builds. - DandersFramesDebugDB = DandersFramesDebugDB or {} - if DandersFramesDebugDB.pixelSession ~= GAP_SESSION then - DandersFramesDebugDB.pixelcheck = nil - DandersFramesDebugDB.pixelSession = GAP_SESSION - end - DandersFramesDebugDB.pixelcheck = DandersFramesDebugDB.pixelcheck or {} - local pdump = { - when = date("%Y-%m-%d %H:%M:%S"), - ppu = ppu, scroll = scroll, - viewTop = dPageTop, viewBot = dPageBot, viewH = dPageH, - rows = {}, - } - for i, r in ipairs(rows) do - pdump.rows[i] = { - label = tostring(r.label):sub(1, 40), kind = r.kind, - dTop = r.dTop, dBot = r.dBot, dH = r.dH, - edgePx = r.edgePx, alpha = r.alpha, clipGap = r.clipGap, - level = r.level, - -- Raw geometry too: the deltas alone cannot distinguish "off-grid" - -- from "the right size but drawn somewhere unexpected". - top = r.frame and r.frame:GetTop() or nil, - bottom = r.frame and r.frame:GetBottom() or nil, - height = r.frame and r.frame:GetHeight() or nil, - width = r.frame and r.frame:GetWidth() or nil, - points = r.frame and r.frame:GetNumPoints() or nil, - } - end - -- Never overwrite an earlier run of the SAME page: the whole point of running - -- this twice is to compare a broken state against a working one, and keying - -- purely by page silently threw the first away. Numbered within the session. - local key, n = tostring(pageName), 1 - while DandersFramesDebugDB.pixelcheck[key] do - n = n + 1 - key = ("%s #%d"):format(tostring(pageName), n) - end - DandersFramesDebugDB.pixelcheck[key] = pdump - print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.pixelcheck[\"%s\"] -- |cffffcc00/reload to flush.|r") - :format(#rows, key)) -end - --- ============================================================ --- /df debug navprobe -- catch the left-nav hover flash in the act --- --- The symptom: sweeping the cursor down the nav list shows a "ghost" -- of the --- row's text, or of the bottom part of the hover plate. It happens at moderate --- speed, not just fast, and it survived snapping the row geometry. --- --- Four causes would produce that, and they are INDISTINGUISHABLE in a --- screenshot, which is why this measures instead of reasoning: --- --- (a) two rows lit at once -- the previous row's OnLeave never ran, so two --- plates are visible together for a frame or two; --- (b) focus thrash -- the cursor sits still over one row but mouse focus --- alternates between it and something else (the scroll frame, a sibling, --- an overlapping rect), so the plate flickers on and off in place; --- (c) geometry -- rows overlap, or leave a dead band between them where --- NOTHING is lit, so crossing it reads as the plate breaking up; --- (d) none of the above -- a pure rendering artefact, in which case the trace --- shows exactly one clean enter/leave per row and the answer is elsewhere. --- --- Static geometry first (overlaps and dead bands are visible without moving the --- mouse), then a live trace of every change in focus and in each row's plate --- alpha, stamped with the frame number. (a) and (b) show up as repeated --- transitions within a single crossing; (c) as a run of frames with nothing lit. -function GUI.NavProbe(seconds) - local container = GUI.tabContainer - if not (container and container:IsVisible()) then - DF:Say("the settings window is not open.") - return - end - local ppu = PixelsPerUnit(container) or 1 - - -- Rows in LAYOUT order (top to bottom), which is what makes the neighbour - -- comparison below meaningful -- GetChildren order is creation order. - local rows = {} - for _, catName in ipairs(GUI.CategoryOrder or {}) do - local cat = GUI.Categories and GUI.Categories[catName] - if cat and cat:IsShown() then - rows[#rows + 1] = { f = cat, label = "[" .. tostring(catName) .. "]" } - for _, btn in ipairs(cat.children or {}) do - if btn:IsShown() then - rows[#rows + 1] = { f = btn, label = DescribeFrame(btn) } - end - end - end - end - - local o = DF:Out("Nav Probe") - o:Section("Rows") - o:Field("visible", #rows, #rows > 0 and "GOOD" or "WARN") - o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") - - -- The ANCESTOR CHAIN, because a row cannot be on the grid if the frame it - -- hangs off is not: every ancestor here is two-corner anchored, so nothing - -- corrects them after the fact and a fraction anywhere propagates to all 42 - -- rows identically. If the rows read a uniform offset, this line says which - -- link introduced it -- that is how the 4-unit nav inset (0.375px) was found - -- after the rows themselves measured clean on height. - local chain, node = {}, container - while node and #chain < 6 do - local t = node:GetTop() - chain[#chain + 1] = ("%s top%+.2f"):format( - (node.GetObjectType and node:GetObjectType() or "?"):sub(1, 6), - PixelOffsetOf(t, PixelsPerUnit(node) or ppu) or 0) - node = node:GetParent() - end - print(" chain (row -> window): " .. table.concat(chain, " | ")) - - -- Geometry: the gap to the row above, in DEVICE pixels. Negative = the rows - -- overlap (both can claim the cursor); more than ~1px positive = a dead band - -- with no row under the cursor at all. Either one produces a visible break. - local overlaps, bands = 0, 0 - for i, r in ipairs(rows) do - local f = r.f - local top, bot, h = f:GetTop(), f:GetBottom(), f:GetHeight() - local dTop = PixelOffsetOf(top, ppu) or 0 - local dH = PixelOffsetOf(h, ppu) or 0 - local gap - if i > 1 then - local prevBot = rows[i - 1].f:GetBottom() - if prevBot and top then gap = (prevBot - top) * ppu end - end - local flag = "" - if gap and gap < -0.05 then flag = " |cffff6060OVERLAPS ABOVE|r"; overlaps = overlaps + 1 - elseif gap and gap > 1.05 then flag = " |cffffaa00DEAD BAND|r"; bands = bands + 1 end - if math.abs(dTop) > 0.05 or math.abs(dH) > 0.05 then - flag = flag .. " |cffffaa00OFF-GRID|r" - end - print((" %-24s top%+.2f h%+.2f gap=%s lvl%d%s"):format( - r.label:sub(1, 24), dTop, dH, - gap and ("%.2fpx"):format(gap) or "n/a", - f:GetFrameLevel() or 0, flag)) - end - print((" %d overlapping rows, %d dead bands between rows"):format(overlaps, bands)) - - -- Live trace. - S.navTrace = S.navTrace or CreateFrame("Frame") - S.navTrace:SetScript("OnUpdate", nil) - local dur = tonumber(seconds) or 8 - local elapsed, frames, events = 0, 0, 0 - local lastLit, lastFocus = nil, nil - print((" |cff00ff00tracing for %ds|r -- sweep the cursor across the nav list now."):format(dur)) - - S.navTrace:SetScript("OnUpdate", function(_, dt) - elapsed = elapsed + dt - frames = frames + 1 - - -- Which row the shared plate is parked on. There is only one plate now, so - -- "TWO LIT" is structurally impossible -- that is the point of the change, - -- and this still checks for it in case the plate is ever reintroduced - -- per-row. No parentheses around the colour call: they would truncate the - -- multi-return to one value and alpha would read nil every frame -- the - -- exact mistake that cost three rounds on the border bug. - local lit = {} - local hl = GUI.navHover - if hl and hl:IsShown() and hl.owner then - local _, _, _, a = hl:GetBackdropColor() - if a and a > 0.01 then - for _, r in ipairs(rows) do - if r.f == hl.owner then lit[#lit + 1] = r.label:sub(1, 18) break end - end - end - end - local litKey = table.concat(lit, "+") - - -- What actually owns the mouse. If this is NOT the lit row, the plate and - -- the focus disagree, which is cause (b). - local focus = "-" - local foci = GetMouseFoci and GetMouseFoci() - local top = (foci and foci[1]) or (GetMouseFocus and GetMouseFocus()) - if top then - for _, r in ipairs(rows) do if r.f == top then focus = r.label:sub(1, 18) break end end - if focus == "-" then - focus = "<" .. ((top.GetObjectType and top:GetObjectType()) or "?") .. ">" - end - end - - if litKey ~= lastLit or focus ~= lastFocus then - events = events + 1 - if events <= 120 then - print((" f%-5d t=%.3f lit=%-24s focus=%s%s"):format( - frames, elapsed, - (litKey ~= "" and litKey or "(none)"), - focus, - (#lit > 1) and " |cffff6060TWO LIT|r" - or ((litKey == "" and focus ~= "-") and " |cffffaa00FOCUS BUT UNLIT|r" or ""))) - end - lastLit, lastFocus = litKey, focus - end - - if elapsed >= dur then - S.navTrace:SetScript("OnUpdate", nil) - print((" |cff00ff00navprobe done|r -- %d frames, %d state changes%s"):format( - frames, events, events > 120 and " (first 120 shown)" or "")) - print(" |cff808080Read: one enter + one leave per row = clean, look elsewhere. Repeated flips inside one crossing = focus thrash. TWO LIT = a stale plate. lit=(none) with focus on a row = the handler did not fire.|r") - end - end) -end - --- ============================================================ --- /df debug gapcheck -- measure the vertical rhythm, don't eyeball it --- --- The question this answers: "which rows are too far apart, and which are too --- close?" It cannot be answered from GUI.RowHeight alone, because those numbers --- are SLOT heights, not gaps. LayoutChildren stacks rows flush (y = y - height), --- so the visible gap between two rows is: --- --- (slot height - content bottom) of the row above --- + (slot top - content top) of the row below --- --- A row whose content is short inside a tall slot gets a big gap and nothing --- flags it. The slider is the worst case by construction: it shares the 55 slot --- with the dropdown and edit box, whose openers reach ~40, while a slider only --- draws to ~32 (label, an 8px track, a 20px value box) -- so it carries ~23px of --- slack against their ~15. --- --- Measured, not derived, for a reason: labels WRAP, override markers hang off --- rows, banners re-measure themselves after construction, and hideOn rows drop --- out. Only the live rects know the real content extent, which is exactly the --- lesson from the border bug -- the arithmetic looked right there too. --- --- Rows are compared only against their SIBLINGS IN THE SAME GROUP. That is where --- the rhythm actually reads, and it sidesteps having to reconstruct which column --- a widget landed in. -local function ContentExtent(f) - local top, bottom - local function acc(o) - if not o or not o.IsShown or not o:IsShown() then return end - -- Skip things that draw NOTHING: an empty label or a fully transparent - -- placeholder still has a rect, and counting it would inflate the content - -- and hide the very slack we are looking for. - -- - -- FontStrings ONLY. An EditBox also answers GetText, and an empty one - -- would have been skipped here even though its box is plainly drawn -- - -- which would have under-measured every blank input on the page. - if o.GetObjectType and o:GetObjectType() == "FontString" then - local s = o:GetText() - if s == nil or s == "" then return end - end - if o.GetAlpha and (o:GetAlpha() or 1) <= 0.01 then return end - local t, b = o:GetTop(), o:GetBottom() - if t and b then - top = (top and math.max(top, t)) or t - bottom = (bottom and math.min(bottom, b)) or b - end - end - if f.GetRegions then for _, r in ipairs({ f:GetRegions() }) do acc(r) end end - if f.GetChildren then for _, c in ipairs({ f:GetChildren() }) do acc(c) end end - return top, bottom -end - --- Every SHOWN SettingsGroup under the page, at any depth (the Aura Designer nests --- its groups inside cards). -local function CollectGroups(root, out, depth) - if not root or (depth or 0) > 8 then return out end - if root.groupChildren and root.IsShown and root:IsShown() then out[#out + 1] = root end - if root.GetChildren then - for _, c in ipairs({ root:GetChildren() }) do - if c.IsShown and c:IsShown() then CollectGroups(c, out, (depth or 0) + 1) end - end - end - return out -end - -function GUI.GapCheck(mode) - if mode == "clear" then - DandersFramesDebugDB = DandersFramesDebugDB or {} - DandersFramesDebugDB.gapcheck = nil - DF:Say("saved capture cleared (/reload to flush).") - return - end - - local page, pageName - for name, p in pairs(GUI.Pages or {}) do - if p.IsShown and p:IsShown() then page, pageName = p, name break end - end - if not page then - DF:Say("Gap check", "no settings page is open", "WARN") - return - end - - local groups = CollectGroups(page.child or page, {}, 0) - local rows, byKind, pairs_, nRows = {}, {}, {}, 0 - - for _, group in ipairs(groups) do - local prev - for _, entry in ipairs(group.groupChildren or {}) do - local w = entry.widget - if w and w.IsShown and w:IsShown() and entry.height then - local slotTop = w:GetTop() - local cTop, cBot = ContentExtent(w) - if slotTop and cTop and cBot then - -- The slot is entry.height from the widget's TOP -- NOT the - -- widget's own rect. LayoutChildren only sets TOPLEFT (and - -- width), so a container constructed at 50 sitting in a 55 - -- slot would under-report by 5 if we used GetBottom(). - local slotBot = slotTop - entry.height - local kind = w.rowKind or (w.LayoutChildren and "group") - or (w.GetObjectType and w:GetObjectType()) or "?" - local r = { - kind = kind, - label = (w.GetText and w:GetText()) or (w.Text and w.Text.GetText and w.Text:GetText()) or kind, - slot = entry.height, - content = cTop - cBot, - padTop = slotTop - cTop, - padBot = cBot - slotBot, - -- Absolute edges, because the gap has to be measured from - -- where the rows LANDED, not from entry.height. The layout - -- can shorten a row after the fact (the compact-run - -- tightening does exactly that), and slot arithmetic - -- cannot see it -- the first version of this reported the - -- untightened number and made the feature look inert. - cTop = cTop, - cBot = cBot, - -- The slot's own top, so the height the layout ACTUALLY - -- used is derivable offline (prev.slotTop - this.slotTop) - -- and can be compared against entry.height. - slotTop = slotTop, - tight = w._rowTightened or false, - nextKind = w._rowNextKind, - } - nRows = nRows + 1 - rows[#rows + 1] = r - - local k = byKind[kind] - if not k then k = { n = 0, slot = 0, content = 0, padTop = 0, padBot = 0 } byKind[kind] = k end - k.n, k.slot, k.content = k.n + 1, k.slot + r.slot, k.content + r.content - k.padTop, k.padBot = k.padTop + r.padTop, k.padBot + r.padBot - - -- The gap the eye actually sees: the distance between where - -- the previous row's content ENDED and this one's STARTS, - -- straight off the resolved rects. - if prev then - local gap = prev.cBot - r.cTop - local key = ("%s -> %s"):format(prev.kind, kind) - local p = pairs_[key] - if not p then p = { n = 0, sum = 0, min = gap, max = gap } pairs_[key] = p end - p.n, p.sum = p.n + 1, p.sum + gap - p.min, p.max = math.min(p.min, gap), math.max(p.max, gap) - end - prev = r - end - end - end - end - - if nRows == 0 then - local o = DF:Out("Gap Check", "page " .. tostring(pageName)) - o:Line("No measurable rows — is everything collapsed?", "WARN") - o:Siblings("gapcheck") - return - end - - local o = DF:Out("Gap Check", "page " .. tostring(pageName)) - o:Section("Measured", "UI units") - o:Field("groups", #groups, "NEUTRAL") - o:Field("rows", nRows, "NEUTRAL") - - o:Section("Per kind") - o:Line("slot is what RowHeight hands out; content is what it actually draws.", "NEUTRAL") - local kinds = {} - for kind in pairs(byKind) do kinds[#kinds + 1] = kind end - table.sort(kinds, function(a, b) return (byKind[a].padBot / byKind[a].n) > (byKind[b].padBot / byKind[b].n) end) - for _, kind in ipairs(kinds) do - local k = byKind[kind] - print((" %-12s n=%-3d slot %5.1f content %5.1f padTop %4.1f |cffffcc00padBottom %4.1f|r") - :format(kind, k.n, k.slot / k.n, k.content / k.n, k.padTop / k.n, k.padBot / k.n)) - end - - -- Did the compact-run tightening actually fire? The gap alone cannot say -- - -- it only shows the result -- and reading the source said it should while the - -- measurement said it had not. So count the decision directly, and when a run - -- did NOT close up, name the kind that broke it. - local tightened, compactRows, breakers = 0, 0, {} - for _, r in ipairs(rows) do - if GUI.RowCompact[r.kind] then - compactRows = compactRows + 1 - if r.tight then - tightened = tightened + 1 - elseif r.nextKind then - breakers[r.nextKind] = (breakers[r.nextKind] or 0) + 1 - end - end - end - if compactRows > 0 then - local why = {} - for k, n in pairs(breakers) do why[#why + 1] = ("%s x%d"):format(k, n) end - table.sort(why) - print((" compact-run tightening: |cffffcc00%d/%d|r compact rows closed up%s") - :format(tightened, compactRows, - #why > 0 and (" |cff808080(run broken by: %s)|r"):format(table.concat(why, ", ")) or "")) - end - - print(" gaps between stacked rows (padBottom above + padTop below) -- widest first:") - local keys = {} - for key in pairs(pairs_) do keys[#keys + 1] = key end - table.sort(keys, function(a, b) return (pairs_[a].sum / pairs_[a].n) > (pairs_[b].sum / pairs_[b].n) end) - for _, key in ipairs(keys) do - local p = pairs_[key] - local avg = p.sum / p.n - -- A pair whose min and max differ is NOT a spacing constant -- something - -- (a wrapped label, a hand-rolled AddSpace) is varying it, and averaging - -- would hide exactly that. - local spread = (p.max - p.min > 0.5) - and (" |cffff6060varies %.1f..%.1f|r"):format(p.min, p.max) or "" - print((" %6.1f %-26s x%d%s"):format(avg, key, p.n, spread)) - end - - if mode == "all" then - print(" every row, in layout order:") - for _, r in ipairs(rows) do - print((" %-12s %-22s slot %5.1f content %5.1f padTop %4.1f padBottom %4.1f") - :format(r.kind, tostring(r.label):sub(1, 22), r.slot, r.content, r.padTop, r.padBot)) - end - end - - -- Persist the RAW rows to SavedVariables. Reading a hundred rows out of the - -- chat frame is not practical, and transcribing them by hand would introduce - -- exactly the kind of error this probe exists to avoid. - -- - -- Keyed BY PAGE so visiting several pages accumulates one dataset instead of - -- each run overwriting the last -- walk the pages you care about, then - -- /reload ONCE. SavedVariables are only flushed on logout or reload, so a run - -- that is never followed by one is never written. - -- - -- Its own saved variable, not a corner of DandersFramesDB_v2: diagnostics - -- must not sit inside the profile DB, where they would ride along with every - -- export and show up in the export audit. - DandersFramesDebugDB = DandersFramesDebugDB or {} - DandersFramesDebugDB.gapcheck = DandersFramesDebugDB.gapcheck or {} - -- Drop everything from a PREVIOUS session first. Accumulating by page is what - -- makes a multi-page capture possible, but it also means a page you did not - -- revisit after a code change keeps its stale rows -- and mixing two builds - -- invents variance that is not in either of them. (It already cost one wrong - -- diagnosis: three stale pages made the row heights look like they had not - -- applied at all.) One /reload = one dataset. - if DandersFramesDebugDB.gapSession ~= GAP_SESSION then - wipe(DandersFramesDebugDB.gapcheck) - DandersFramesDebugDB.gapSession = GAP_SESSION - end - local dump = { when = date("%Y-%m-%d %H:%M:%S"), rows = {} } - if page.GetEffectiveScale then dump.scale = page:GetEffectiveScale() end - for i, r in ipairs(rows) do - dump.rows[i] = { - kind = r.kind, label = tostring(r.label):sub(1, 40), - slot = r.slot, content = r.content, - padTop = r.padTop, padBot = r.padBot, - cTop = r.cTop, cBot = r.cBot, -- so gaps can be re-derived offline - slotTop = r.slotTop, tight = r.tight, nextKind = r.nextKind, - } - end - DandersFramesDebugDB.gapcheck[tostring(pageName)] = dump - - local nPages = 0 - for _ in pairs(DandersFramesDebugDB.gapcheck) do nPages = nPages + 1 end - print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.gapcheck[\"%s\"] -- %d page(s) captured. |cffffcc00Visit the pages you care about, then /reload to flush.|r") - :format(#rows, tostring(pageName), nPages)) - print(" |cff808080Read: padBottom is the slack under a row -- the knob is GUI.RowHeight[kind], and slot - content IS that slack. A kind sorting to the top of the first list is over-spaced; one near zero is cramped. In the second list, 'varies' means the gap is not coming from RowHeight alone. Add 'all' for every row, 'clear' to wipe the saved capture.|r") -end --- (Removed) GUI.GapCheckAll — a no-arg alias for GapCheck("all"), superseded once the --- dispatcher started forwarding the mode argument. Zero callers. - --- Every frame that carries a 1px backdrop edge, so the sweep below can find them --- without any page needing to know what it contains. Weak keys: a retired page's --- widgets are reparented to the trash frame rather than destroyed, and this must --- not be what keeps them reachable. - --- Re-derive the BORDER THICKNESS of everything currently on screen. Since the --- geometry correction was removed this no longer moves or resizes anything, so --- the only thing it can change is how many device pixels an edge is drawn at -- --- which only matters when the SCALE changes. That is why its callers are the --- scale slider and the window drag/resize handlers, not anything per-frame. --- --- IsVisible (not IsShown) keeps it cheap: a widget on a page that is not open --- has a hidden ancestor and is skipped, so the work stays proportional to what --- is displayed rather than to everything the registry has accumulated. Safe to --- call repeatedly -- it writes only when the computed edge width actually --- differs, so a second call in the same state does nothing at all. - --- ============================================================ --- PIXEL BORDER -- how every outlined GUI surface draws its edge. --- --- Four textures we own, instead of a backdrop's edgeFile, because a backdrop --- edge is drawn one UI unit wide and we cannot control how that lands on the --- physical pixel grid. At the scales people play at, one unit is about one --- device pixel, and a one-device-pixel line has exactly two states: [1.0, 0] --- when it lands on the grid and [0.5, 0.5] when it does not. Identical ink, --- completely different appearance -- so an edge that crosses the grid while the --- page scrolls appears to flicker, and at the low alphas this GUI uses, both --- halves of the split can fall under the visibility floor and the border simply --- is not there. --- --- The long way round to this was trying to place that 1px line perfectly. It --- cannot be done. Lua-side snapping runs at discrete moments -- build, --- scroll-settle, show -- but the line crosses the grid on EVERY frame of a --- scroll: correcting at moment N does nothing for the next thirty frames, and --- correcting mid-motion adds a visible jump on top of the softness. Handing it --- to the renderer (SetSnapToPixelGrid) is worse still: it rounds the two edges --- of a thin texture independently, so a 1px line can round to zero height and --- vanish outright. --- --- The answer is to stop placing a thin line accurately and draw one that does --- not care where it lands. See PX_BORDER_THICKNESS below. -local pixelBordered = setmetatable({}, { __mode = "k" }) -local PX_SIDES = { "top", "bottom", "left", "right" } - --- THE dial. Border thickness in DEVICE PIXELS, before any per-surface weight. --- --- The floor is what matters, not the exact value: anything above 1 always covers --- at least one FULL pixel row plus a partial, so the line can never vanish and --- never changes its total ink as the content moves. Only 1.0 has the two-state --- failure ([1.0, 0] when it lands on the grid, [0.5, 0.5] when it does not -- --- identical ink, completely different appearance), which is the flicker that --- started this. --- --- Back to 2, and the alpha carries the weight instead. THICKNESS and WEIGHT are --- separate dials and conflating them was the mistake: --- --- * thickness decides how UNIFORM the line looks. At 1.5 the rows come out --- [0.25, 1.0, 0.25] or [0.5, 1.0] depending on where the box lands, so --- neighbouring boxes render visibly different widths -- Krathe's "one --- thinner, one thicker". The bigger the base, the smaller that proportion, --- so 2 is noticeably more even than 1.5. --- * alpha decides how HEAVY it looks, and does not vary with position at all. --- --- So: hold thickness at the value that renders evenly, and take the weight out --- of the alpha. 2px at 0.7 alpha is about the same total ink as 1.5px at full, --- but without the width wobble. --- --- The floor still matters if this is ever tuned down: anything above 1 always --- covers a full row plus a partial, so it cannot vanish. Only 1.0 has the --- two-state failure -- [1.0, 0] on the grid, [0.5, 0.5] off it, identical ink --- and completely different appearance -- which was the original flicker. -local PX_BORDER_THICKNESS = 2 - --- Applied to every pixel border's alpha. The authored colours were all chosen --- for a 1px hairline; drawing them at 2px would read heavier than intended, and --- this puts that correction in ONE place rather than re-tuning six call sites --- (and whatever opts in later) by hand. -local PX_BORDER_ALPHA = 0.7 - --- Thickness and anchors, re-derived whenever the scale changes. --- frame._pxWeight is the caller's edgeSize (1 = the standard hairline), so a --- surface that asked for a heavier outline keeps its relative weight. -local function LayoutPixelBorder(frame) - local b = frame._pxBorder - if not b then return end - local ppu = PixelsPerUnit(frame) - if not ppu then return end - -- TWO device pixels, not one, and this is the whole finding. - -- - -- A 1px line cannot be drawn reliably at a fractional offset. With vertex - -- snapping ON, the top and bottom of a 1px-tall texture can round to the SAME - -- pixel row -- height zero, line GONE. That is what the red diagnostic showed: - -- verticals solid (X is stable), horizontals absent at certain scroll - -- positions (Y carries the scroll's fractional phase). The old backdrop edge - -- did it too, so this is not specific to either mechanism. - -- - -- With snapping OFF at 2px the rows come out [partial, full, partial] -- - -- roughly 0.5 / 1.0 / 0.5 -- for ANY offset. The total ink is constant and - -- there is always at least one fully-lit row, so the line can neither vanish - -- nor visibly change weight as the page scrolls. A 1px line has only the two - -- states [1.0, 0] and [0.5, 0.5]: same ink, completely different appearance, - -- which IS the flicker. - -- - -- So: stop trying to place a 1px line perfectly, and draw one that does not - -- care where it lands. - local devicePx = PX_BORDER_THICKNESS * (frame._pxWeight or 1) - local px = devicePx / ppu - frame._pxDevicePx = devicePx -- what /df debug pixelcheck reports for this surface - frame._pxPpu = ppu -- the scale this thickness was derived at - - b.top:ClearAllPoints() - b.top:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - b.top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) - b.top:SetHeight(px) - - b.bottom:ClearAllPoints() - b.bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) - b.bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - b.bottom:SetHeight(px) - - b.left:ClearAllPoints() - b.left:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - b.left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) - b.left:SetWidth(px) - - b.right:ClearAllPoints() - b.right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) - b.right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - b.right:SetWidth(px) -end - --- THE SHIM, and the reason buttons could not adopt this until now. --- --- A pixel border has no backdrop edgeFile, so SetBackdropBorderColor -- which is --- how ~150 call sites across the addon drive hover, active and disabled states --- -- would silently do nothing. Converting those by hand meant finding all of --- them, and missing one meant a button whose hover just stops working, with --- nothing to see in a log. --- --- So the frame gets its own method that shadows the mixin's. Every existing --- caller keeps working unchanged, and there is no list to be exhaustive about. --- Assigning onto the frame table is enough: SetBackdropBorderColor is not a --- native widget method, it arrives via BackdropTemplateMixin, so it is already --- a plain table entry and ours simply takes its place. -local function SetPixelBorderColor(self, r, g, b, a) - local bd = self._pxBorder - if not bd then return end - a = a or 1 - -- Mutated, not replaced. This is the hover path for every button, dropdown - -- and swatch in the GUI -- OnEnter and OnLeave both land here -- so a fresh - -- table per call would be pure garbage. - local c = self._pxColor - if c then c[1], c[2], c[3], c[4] = r, g, b, a - else self._pxColor = { r, g, b, a } end - -- Alpha scaled once, here, so the authored colours stay the values a reader - -- would expect to see (8%, 50%, 80%) rather than pre-compensated numbers. - local drawn = a * PX_BORDER_ALPHA - for _, side in ipairs(PX_SIDES) do - bd[side]:SetColorTexture(r, g, b, drawn) - end -end - --- Returns the AUTHORED colour, not the drawn one. PX_BORDER_ALPHA is a --- rendering correction, so a caller that reads a colour back and writes it again --- must not end up with it applied twice. -local function GetPixelBorderColor(self) - local c = self._pxColor - if not c then return end - return c[1], c[2], c[3], c[4] -end - --- Re-derive on show, so a surface built at one UI scale and opened after a scale --- change comes up at the right thickness. Self-registering on purpose: this --- used to be an explicit call that six floating windows each had to remember to --- make, and a bordered frame maintaining its own border cannot be forgotten. --- --- Guarded on the scale it was last laid out at, because this fires for every --- widget on every page open. In the ordinary case -- nothing has changed since --- the widget was built -- it costs one float compare and returns. -local function RelayoutPixelBorderOnShow(self) - if PixelsPerUnit(self) ~= self._pxPpu then LayoutPixelBorder(self) end -end - --- color = {r, g, b, a}; weight mirrors backdrop edgeSize (1 = standard hairline) -function GUI:ApplyPixelBorder(frame, color, weight) - frame._pxWeight = weight or frame._pxWeight or 1 - local b = frame._pxBorder - if not b then - b = {} - for _, side in ipairs(PX_SIDES) do - -- ARTWORK at the top sublevel, not BORDER. The colour picker is - -- what forces it up: its hue square lays a full-size ARTWORK - -- gradient over the frame, which would cover a BORDER-layer edge - -- completely -- and that is a trap, because the border would - -- disappear for a reason nothing about the border explains. - -- - -- Not OVERLAY, though. Every label, icon, check mark, grip and - -- slider thumb in this GUI is OVERLAY, and those belong above the - -- edge they sit inside. Sublevel 7 of ARTWORK clears the interior - -- fills and gradients and nothing else. - local tex = frame:CreateTexture(nil, "ARTWORK", nil, 7) - -- Snapping OFF, deliberately, and this is a reversal of what this - -- started out as. Vertex snapping is what COLLAPSES a thin line: - -- rounding a 2px texture's edges independently gives a height of 1, - -- 2 or 3 px depending on where it lands, so the border would - -- visibly change weight as you scroll. Left un-snapped it is always - -- exactly 2px of ink, wherever it falls -- constant, which is what - -- the eye actually wants. Crispness was never the goal; STABILITY - -- was. - if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end - -- No half-texel offset, so the 2px spans the rows we asked for. - if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end - b[side] = tex - end - frame._pxBorder = b - pixelBordered[frame] = true - if frame.HookScript then - frame:HookScript("OnShow", RelayoutPixelBorderOnShow) - end - end - -- Outside the create block on purpose: the textures are kept when a surface - -- is re-issued, so keying the shim off their creation would leave a - -- re-adopted frame with the real methods still in place and its recolours - -- going nowhere. Assigning every time is idempotent and cannot get this - -- wrong. - frame.SetPixelBorderColor = SetPixelBorderColor - frame.SetBackdropBorderColor = SetPixelBorderColor - frame.GetBackdropBorderColor = GetPixelBorderColor - local c = color or { 1, 1, 1, 0.08 } - SetPixelBorderColor(frame, c[1], c[2], c[3], c[4] or 1) - -- Re-shown because a surface can be re-issued as fill-only and back again - -- (FlashWidget does this on every pulse). - for _, side in ipairs(PX_SIDES) do b[side]:Show() end - LayoutPixelBorder(frame) - return frame -end - --- Hand the frame its own methods back, and take the textures down. Needed by the --- one path that leaves the pixel border behind: a surface re-issued with --- opts.backdropEdge after it had one. Nothing does that today, but if the shim --- were left installed the backdrop recolour that follows would go nowhere at --- all -- a hover that silently stops working, which is precisely the failure --- this system exists to make impossible. -local function RevertPixelBorder(frame) - if not frame or not frame._pxBorder then return end - GUI:HidePixelBorder(frame) - frame.SetPixelBorderColor = nil - frame.SetBackdropBorderColor = BackdropTemplateMixin.SetBackdropBorderColor - frame.GetBackdropBorderColor = BackdropTemplateMixin.GetBackdropBorderColor -end - --- Take the border down without discarding it. The textures are ours, so unlike a --- backdrop edge nothing else will remove them when the surface is re-issued --- without an outline. -function GUI:HidePixelBorder(frame) - local b = frame and frame._pxBorder - if not b then return end - for _, side in ipairs(PX_SIDES) do b[side]:Hide() end -end - --- Re-derive thickness on a scale change. Rides the same sweep that already --- re-derives backdrop edge widths, so there is no new per-frame work. -function GUI:RefreshPixelBorders() - for frame in pairs(pixelBordered) do - if frame:IsVisible() then LayoutPixelBorder(frame) end - end -end - --- THE element backdrop. Every bordered/filled GUI surface goes through here, so --- a change to the look lands everywhere at once. Three shapes, one code path: --- --- (default) fill + 1px outline -- dropdowns, edit boxes, buttons --- opts.fill=false outline only -- for a surface whose interior is drawn by --- something else (the colour picker's hue/alpha gradients --- and checkerboards), where a fill would paint over it --- opts.outline=false fill only -- flat chips, segment buttons, label plates --- --- opts.bgColor / opts.borderColor take {r,g,b[,a]} or {[1],[2],[3][,4]} and --- override the C_ELEMENT / C_BORDER defaults. opts.edgeSize thickens the outline --- (the popup and wizard chrome use 2). opts.inset (a --- single number, applied to all four sides) pulls the fill in from the edge so it --- does not underlap a translucent border -- default 0, i.e. the fill runs to the --- frame edge. opts.backdropEdge draws the outline the old way, as a backdrop --- edgeFile; see below for why nothing should want that. -local function CreateElementBackdrop(frame, opts) - opts = opts or {} - local fill, outline = opts.fill ~= false, opts.outline ~= false - -- The outline is drawn as our own textures, not a backdrop edgeFile. See - -- ApplyPixelBorder -- a 1px backdrop edge cannot be drawn reliably at a - -- fractional offset, which is the whole missing-border story. - -- - -- This was an opt-in while the hover states were still a problem: a pixel - -- border is not repainted by SetBackdropBorderColor, so converting buttons - -- blind would have silently killed every hover in the GUI. The shim in - -- ApplyPixelBorder settles that -- the frame gets its own - -- SetBackdropBorderColor -- so it is now the default for everything that - -- comes through here, and opts.backdropEdge is the escape hatch for a - -- surface that genuinely needs the old edgeFile. - local usePixel = outline and not opts.backdropEdge - if not frame.SetBackdrop then Mixin(frame, BackdropTemplateMixin) end - local inset = opts.inset - frame:SetBackdrop({ - bgFile = fill and "Interface\\Buttons\\WHITE8x8" or nil, - edgeFile = (outline and not usePixel) and "Interface\\Buttons\\WHITE8x8" or nil, - edgeSize = (outline and not usePixel) and (opts.edgeSize or 1) or nil, - insets = inset and { left = inset, right = inset, - top = inset, bottom = inset } or nil, - }) - if fill then - local c = opts.bgColor - if c then frame:SetBackdropColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) - else frame:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, C_ELEMENT.a) end - end - if outline then - local c = opts.borderColor - if usePixel then - GUI:ApplyPixelBorder(frame, - c and { c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1 } - or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5 }, - opts.edgeSize) - else - RevertPixelBorder(frame) - if c then - frame:SetBackdropBorderColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) - else - frame:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - end - end - else - -- A backdrop edge disappears on its own when the backdrop is re-issued - -- without one; our textures do not, so an outlined surface re-issued as - -- fill-only has to be told. FlashWidget does exactly this: it re-runs - -- this factory on every pulse, with outline true or false depending on - -- how the caller wants that pulse to read. - GUI:HidePixelBorder(frame) - end - return frame -end - --- The window chrome: the settings window itself, the changelog overlay and the --- dropdown menu panel. Distinct from the public GUI:CreatePanelBackdrop further --- down (dialogs and floating panels, colour-configurable) -- this one is always --- the dark background behind a hard black edge. --- --- Routed through the factory rather than issuing its own backdrop, which is what --- it used to do. It was the last thing in the GUI still drawing a border as an --- edgeFile, and a black 1px line is the case where that reads least badly -- it --- has enough contrast that a split across two device rows softens it instead of --- losing it. Still worth converting: softening on every scroll is what the whole --- pixel border exists to stop, and while this was the last holdout the entire --- snap-registry existed to serve three call sites. -local function CreatePanelBackdrop(frame) - return CreateElementBackdrop(frame, { - bgColor = C_BACKGROUND, - borderColor = { 0, 0, 0, 1 }, - }) -end - --- Style a ScrollFrameTemplate scrollbar to use the pill-shaped thumb --- All scroll frames must use ScrollFrameTemplate (not UIPanelScrollFrameTemplate) --- --- This used to also quantise the scroll offset to whole device pixels, on the --- reasoning that Blizzard's scrollbar drives the offset as a fraction of the --- range and so parks the content on an arbitrary sub-pixel row. That was true, --- and it did not help: a thin border is soft at SOME offsets no matter which --- offsets you allow, and quantising only changed which ones. The border being --- two device pixels wide is what made the question stop mattering -- it draws --- the same amount of ink wherever it lands. Do not add it back. -local function StyleScrollBar(scrollFrame) - local sb = scrollFrame.ScrollBar - if not sb then return end - - -- Hide track background and track end caps - if sb.Background then sb.Background:Hide() end - if sb.Track then - if sb.Track.Begin then sb.Track.Begin:Hide() end - if sb.Track.End then sb.Track.End:Hide() end - if sb.Track.Middle then sb.Track.Middle:Hide() end - end - - -- Style the pill-shaped thumb — hide default textures, overlay with themed color - if sb.Thumb then - if sb.Thumb.Begin then sb.Thumb.Begin:Hide() end - if sb.Thumb.End then sb.Thumb.End:Hide() end - if sb.Thumb.Middle then sb.Thumb.Middle:Hide() end - if not sb.Thumb.customBg then - local thumb = sb.Thumb:CreateTexture(nil, "ARTWORK") - thumb:SetAllPoints() - thumb:SetColorTexture(0.4, 0.4, 0.4, 0.8) - sb.Thumb.customBg = thumb - end - end - - -- Hide navigation buttons - if sb.Back then sb.Back:Hide() sb.Back:SetSize(1, 1) end - if sb.Forward then sb.Forward:Hide() sb.Forward:SetSize(1, 1) end - - -- Slim width - sb:SetWidth(10) -end -GUI.StyleScrollBar = StyleScrollBar - --- ========================================================================= --- WIDGET FACTORY --- ========================================================================= - -function GUI:CreateHeader(parent, text) - -- Use a frame container so we can position text at bottom (padding above) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(200, 25) - container:Show() - container.rowKind = "header" - -- Factory-owned slot, and fixed so ResolveRowHeight ignores whatever the call - -- site passes. Headers were handed 25 in collapsible groups and 40 in plain - -- ones -- the same widget, two rhythms, across ~200 sites. Owning it here - -- unifies them without touching any of those call sites, which is exactly the - -- rule the other factory rows already follow (see GUI.RowHeight). - container.preferredHeight = GUI.RowHeight.sectionHeader - container.fixedRowHeight = true - - local h = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") - h:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 0, 2) - h:SetText(text) - local c = GetThemeColor() - h:SetTextColor(c.r, c.g, c.b) - h:SetJustifyH("LEFT") - h.UpdateTheme = function() local nc = GetThemeColor() h:SetTextColor(nc.r, nc.g, nc.b) end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, h) - - -- Store text reference - container.text = h - - -- Forward IsShown to ensure layout works - container.GetText = function() return h:GetText() end - - -- SEARCH: Track current section - if DF.Search then - DF.Search:SetCurrentSection(text) - end - - return container -end - --- Collapsible section for grouping related settings. --- Collapsed state is persisted in DandersFramesDB_v2.collapsedGroups keyed by --- `text` (shared store with CreateSettingsGroup's collapsible header), so the --- user's fold preference survives reloads. -function GUI:CreateCollapsibleSection(parent, text, defaultExpanded, width) - local section = CreateFrame("Frame", nil, parent, "BackdropTemplate") - section:SetSize(width or 500, 28) -- Header height - -- Resolve initial expanded state: SavedVariables override the default. - local savedStates = GUI:GetCollapsedGroups() - if text and savedStates[text] ~= nil then - section.expanded = not savedStates[text] - else - section.expanded = defaultExpanded ~= false - end - section.sectionTitleText = text - section.sectionChildren = {} - -- Same contract as CreateHeader's container.GetText: it is how a section is FOUND - -- by title (Search:ScrollToSection, and every GUI:LinkToSetting{ section = ... } - -- cross-link through it). Without it a collapsible section is invisible to those - -- lookups, so a link to it silently does nothing — which is exactly what happened - -- to the Colours page's "Color by Time" links when that box was promoted from a - -- plain header to a collapsible section. - section.GetText = function(self) return self.sectionTitleText end - section.paddingAfter = 8 -- Padding space after header before first child - - -- Header bar with background. Same look as before, via the shared backdrop - -- helper rather than a private copy of it, so it picks up the pixel border - -- (and anything else that lands there) without its own wiring. - GUI:CreateElementBackdrop(section, { - bgColor = { r = C_PANEL.r, g = C_PANEL.g, b = C_PANEL.b, a = 0.8 }, - borderColor = { r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5 }, - }) - - -- Click area - local clickArea = CreateFrame("Button", nil, section) - clickArea:SetAllPoints() - clickArea:EnableMouse(true) - - -- Expand/collapse arrow icon - section.arrow = section:CreateTexture(nil, "OVERLAY") - section.arrow:SetPoint("LEFT", 8, 0) - section.arrow:SetSize(12, 12) - if section.expanded then - section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - else - section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") - end - section.arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Section title - section.title = section:CreateFontString(nil, "OVERLAY", "DFFontNormal") - section.title:SetPoint("LEFT", 26, 0) - section.title:SetText(text) - local c = GetThemeColor() - section.title:SetTextColor(c.r, c.g, c.b) - section.title.UpdateTheme = function() - if section.previewDimmed then - section.title:SetTextColor(0.5, 0.5, 0.5) - else - local nc = GetThemeColor() - section.title:SetTextColor(nc.r, nc.g, nc.b) - end - end - -- Grey the header title when the section's feature is disabled (driven by - -- the preview wiring). Routes through UpdateTheme so theme changes respect it. - section.SetPreviewDimmed = function(self, dimmed) - self.previewDimmed = dimmed and true or false - self.title.UpdateTheme() - end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, section.title) - - -- Optional inline tag — small yellow text placed after the title to - -- stand out as a status summary (e.g. "[Normal Dispels]"). Call - -- section:SetTag(text) at any time; pass nil or empty string to clear. - section.tag = section:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - section.tag:SetPoint("LEFT", section.title, "RIGHT", 8, 0) - section.tag:SetTextColor(1, 0.82, 0, 1) -- WoW standard gold/yellow - section.tag:SetText("") - section.SetTag = function(self, text) - if text and text ~= "" then - self.tag:SetText(text) - self.tag:Show() - else - self.tag:SetText("") - self.tag:Hide() - end - end - - -- SEARCH: Track current section - if DF.Search then - DF.Search:SetCurrentSection(text) - end - - -- Toggle function - section.Toggle = function(self) - self.expanded = not self.expanded - if self.expanded then - self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - else - self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") - end - -- Persist collapsed state to SavedVariables (only store true, remove when expanded) - if self.sectionTitleText then - local saved = GUI:GetCollapsedGroups() - saved[self.sectionTitleText] = (not self.expanded) or nil - end - - -- Trigger layout refresh (RefreshStates handles show/hide based on expanded state) - if parent.RefreshStates then - parent:RefreshStates() - end - end - - -- Register child widgets to this section - section.RegisterChild = function(self, widget) - table.insert(self.sectionChildren, widget) - widget.parentSection = self - - -- Use a marker to check section state during RefreshStates - widget.collapsibleSection = self - end - - -- Optional header preview thumbnails — a right-aligned row of small icon - -- swatches on the header bar, used to show the actual icon(s) a section - -- controls (e.g. the Role Icon section previews the Tank/Healer/DPS icons in - -- the currently selected style). Always visible on the header, so the page - -- reads as a gallery whether sections are expanded or collapsed. - -- - -- icons: array of entries, each EITHER an icon or a text label: - -- { texture = "atlas-or-path", coords = {l,r,t,b}?, desaturate = bool? } - -- { text = "MT", desaturate = bool? } - -- Icon entries are fixed-width swatches; text entries are sized to the - -- string. Entries flow right-to-left from the header's right edge so the - -- first entry sits leftmost. nil/empty clears the preview. - section.previewIcons = {} - section.SetPreviewIcons = function(self, icons) - local pool = self.previewIcons - local n = icons and #icons or 0 - local SIZE, GAP, RIGHT_INSET = 18, 4, -10 - local x = RIGHT_INSET - for i = n, 1, -1 do -- right-to-left so entry 1 ends up leftmost - local data = icons[i] - local slot = pool[i] - if not slot then - slot = CreateFrame("Frame", nil, self) - slot:SetHeight(SIZE) - slot.tex = slot:CreateTexture(nil, "OVERLAY") - slot.tex:SetAllPoints() - slot.fs = slot:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - slot.fs:SetAllPoints() - slot.fs:SetJustifyH("CENTER") - pool[i] = slot - end - local dim = data.desaturate and true or false - local w = SIZE - if data.text and data.text ~= "" then - slot.tex:Hide() - slot.fs:SetText(data.text) - if dim then - slot.fs:SetTextColor(0.5, 0.5, 0.5, 1) - elseif data.color then - slot.fs:SetTextColor(data.color.r or 1, data.color.g or 1, data.color.b or 1, data.color.a or 1) - else - slot.fs:SetTextColor(1, 0.82, 0, 1) - end - slot.fs:Show() - w = math.max(SIZE, (slot.fs:GetStringWidth() or 0) + 4) - else - slot.fs:Hide() - -- data.texture may be an atlas name or a texture path; the helper - -- prefers the atlas and falls back to the path (+ optional coords). - local co = data.coords - DF:SetIconTextureOrAtlas(slot.tex, data.texture, co and co[1], co and co[2], co and co[3], co and co[4]) - slot.tex:SetDesaturated(dim) - -- Optional per-entry inset: textures that fill their cell edge-to-edge - -- (e.g. raid-target markers) read bigger than the padded status-icon - -- atlases. data.inset shrinks the swatch to match. - local pad = data.inset or 0 - slot.tex:ClearAllPoints() - slot.tex:SetPoint("TOPLEFT", slot, "TOPLEFT", pad, -pad) - slot.tex:SetPoint("BOTTOMRIGHT", slot, "BOTTOMRIGHT", -pad, pad) - slot.tex:Show() - end - slot:SetWidth(w) - slot:ClearAllPoints() - slot:SetPoint("RIGHT", self, "RIGHT", x, 0) - slot:Show() - x = x - w - GAP - end - for i = n + 1, #pool do pool[i]:Hide() end - end - - -- Hover effects - clickArea:SetScript("OnEnter", function() - section:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.8) - end) - clickArea:SetScript("OnLeave", function() - section:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.8) - end) - clickArea:SetScript("OnClick", function() - section:Toggle() - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - - return section -end - --- ========================================================================= --- SETTINGS GROUP - Visible container that groups related settings together --- Ensures settings never get split across columns --- ========================================================================= --- Collapsed state persistence (stored in SavedVariables, survives logout) --- Lazily initialized from DandersFramesDB_v2.collapsedGroups on first access -function GUI:GetCollapsedGroups() - if not DandersFramesDB_v2 then return {} end - if not DandersFramesDB_v2.collapsedGroups then - DandersFramesDB_v2.collapsedGroups = {} - end - return DandersFramesDB_v2.collapsedGroups -end - -function GUI:CreateSettingsGroup(parent, width, opts) - -- opts can be a boolean (legacy: collapsible) or a table { collapsible, showSummary, onCollapseChanged } - if type(opts) == "boolean" then opts = { collapsible = opts } end - opts = opts or {} - - local group = CreateFrame("Frame", nil, parent, "BackdropTemplate") - group.onCollapseChanged = opts.onCollapseChanged - group:SetSize(width or 280, 10) -- Height will be calculated dynamically - group.groupChildren = {} - group.isSettingsGroup = true - group.collapsible = opts.collapsible or false - group.showSummary = opts.showSummary or false - -- Optional saved-state key override: lets several boxes share a standard - -- display header (e.g. "Appearance") while persisting collapse state under a - -- unique key (e.g. "afkIcon:Appearance"), so they don't toggle together. - group.collapseKey = opts.collapseKey - group.collapsed = false - - -- Visual styling - subtle background and border - local padding = 10 - local margin = 10 -- Space between groups - group.padding = padding - group.margin = margin - - -- 8%, and worth knowing why it is not 16%: it was, for a while, because an - -- 8% edge that split across two device rows left 4% on each and neither was - -- visible. Doubling it made the surviving half readable at the cost of the - -- whole border reading too heavy when it did NOT split. The border no longer - -- has to survive being split, because it no longer splits -- so the alpha - -- went back to the value that was right in the first place. - CreateElementBackdrop(group, { - bgColor = { 1, 1, 1, 0.03 }, -- very subtle white background (3%) - borderColor = { 1, 1, 1, 0.08 }, -- subtle white border (8%) - }) - - -- Bottom collapse bar (only for collapsible groups, shown when expanded) - if group.collapsible then - local collapseBar = CreateFrame("Button", nil, group) - collapseBar:SetHeight(14) - collapseBar:SetPoint("BOTTOMLEFT", group, "BOTTOMLEFT", 1, 1) - collapseBar:SetPoint("BOTTOMRIGHT", group, "BOTTOMRIGHT", -1, 1) - - local barBg = collapseBar:CreateTexture(nil, "BACKGROUND") - barBg:SetAllPoints() - barBg:SetColorTexture(1, 1, 1, 0.03) - - local barIcon = collapseBar:CreateTexture(nil, "OVERLAY") - barIcon:SetSize(12, 12) - barIcon:SetPoint("CENTER", 0, 0) - local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - -- "expand_more" is a down chevron; rotate 180° so it points UP — this bar - -- collapses the (expanded) section, so an up arrow reads correctly. - barIcon:SetTexture(mediaPath .. "expand_more") - barIcon:SetRotation(math.pi) - barIcon:SetVertexColor(1, 1, 1, 0.5) - - collapseBar:SetScript("OnEnter", function() - barBg:SetColorTexture(1, 1, 1, 0.06) - barIcon:SetVertexColor(1, 1, 1, 0.85) - end) - collapseBar:SetScript("OnLeave", function() - barBg:SetColorTexture(1, 1, 1, 0.03) - barIcon:SetVertexColor(1, 1, 1, 0.5) - end) - collapseBar:SetScript("OnClick", function() - group.collapsed = true - local headerText = group.headerWidget and group.headerWidget.text and group.headerWidget.text:GetText() - local stateKey = group.collapseKey or headerText - if stateKey then - local saved = GUI:GetCollapsedGroups() - saved[stateKey] = true - end - if group.collapseArrow then - group.collapseArrow:SetTexture(mediaPath .. "chevron_right") - end - if DF.AuraDesigner_RefreshPage then - DF:AuraDesigner_RefreshPage() - end - local pageChild = group:GetParent() - if pageChild and pageChild.RefreshStates then pageChild.RefreshStates() end - if group.onCollapseChanged then group.onCollapseChanged(group) end - end) - - collapseBar:Hide() - group.collapseBar = collapseBar - end - - -- Add a widget to this group - group.AddWidget = function(self, widget, height) - widget:SetParent(self) - -- Record whether the CALL SITE pinned this slot's height. A self-measuring - -- widget (CreateLabel) may only re-flow the group when it did not — an - -- explicit number stays authoritative, so no existing layout can shift. - widget._slotHeightExplicit = (height ~= nil) or nil - table.insert(self.groupChildren, { - widget = widget, - height = ResolveRowHeight(widget, height), - }) - -- Mark widget as belonging to this group - widget.settingsGroup = self - - -- If collapsible and this is the first widget (header), set up collapse toggle - if self.collapsible and #self.groupChildren == 1 and widget.text then - self.headerWidget = widget - - -- Resolve collapsed state: default to expanded unless saved state says collapsed - local headerText = widget.text:GetText() - local stateKey = self.collapseKey or headerText - local savedStates = GUI:GetCollapsedGroups() - if stateKey and savedStates[stateKey] then - self.collapsed = true - else - self.collapsed = false - end - - -- Shift header text right to make room for the arrow icon - widget.text:ClearAllPoints() - widget.text:SetPoint("BOTTOMLEFT", widget, "BOTTOMLEFT", 14, 2) - - -- Add toggle arrow icon (texture from Media folder) - local arrow = widget:CreateTexture(nil, "OVERLAY") - arrow:SetSize(10, 10) - arrow:SetPoint("RIGHT", widget.text, "LEFT", -2, 0) - local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - arrow:SetTexture(self.collapsed and (mediaPath .. "chevron_right") or (mediaPath .. "expand_more")) - local c = GetThemeColor() - arrow:SetVertexColor(c.r, c.g, c.b) - self.collapseArrow = arrow - - -- Theme listener for arrow color - arrow.UpdateTheme = function() - local nc = GetThemeColor() - arrow:SetVertexColor(nc.r, nc.g, nc.b) - end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, arrow) - - -- Make the header clickable - widget:EnableMouse(true) - widget:SetScript("OnMouseDown", function() - self.collapsed = not self.collapsed - -- Persist collapsed state to SavedVariables - if stateKey then - local saved = GUI:GetCollapsedGroups() - saved[stateKey] = self.collapsed or nil -- only store true, remove when expanded - end - arrow:SetTexture(self.collapsed and (mediaPath .. "chevron_right") or (mediaPath .. "expand_more")) - -- Refresh the page to recalculate layout. The Aura Designer page - -- has its own refresh; BuildPage pages (icons, frame settings…) - -- expose RefreshStates on the group's parent (self.child). - if DF.AuraDesigner_RefreshPage then - DF:AuraDesigner_RefreshPage() - end - local pageChild = self:GetParent() - if pageChild and pageChild.RefreshStates then pageChild.RefreshStates() end - if self.onCollapseChanged then self.onCollapseChanged(self) end - end) - - -- Highlight arrow on hover to indicate clickable - widget:SetScript("OnEnter", function() - arrow:SetVertexColor(1, 1, 1) - end) - widget:SetScript("OnLeave", function() - local nc = GetThemeColor() - arrow:SetVertexColor(nc.r, nc.g, nc.b) - end) - end - - return widget - end - - -- Calculate total height based on visible children and layout them - group.LayoutChildren = function(self) - -- Snapped padding: every child's left edge and first row start from it, so - -- if it is a fractional number of device pixels the whole column inherits - -- that offset. See SnapLen. - local padding = SnapLen(self, self.padding) - local y = -padding -- Start with top padding - local visibleCount = 0 - -- Width for child widgets. A group whose width is not resolved yet (created but - -- not laid out, or anchors cleared) yields a non-positive innerWidth. Do NOT - -- substitute a guessed width — a group can legitimately be far wider than its - -- constructed size (RefreshStates stretches layoutCol "both" groups to the full - -- content width), so guessing squeezes those children and truncates their text. - -- Skip the sizing instead and let the next pass, with a real width, do it. That - -- matches the old behaviour, where a negative SetWidth was silently a no-op. - local innerWidth = SnapLen(self, (self:GetWidth() or 0) - (padding * 2)) - local canSize = innerWidth > 0 - - -- Will this entry be laid out on this pass? Factored out of the loop below - -- so the run look-ahead cannot drift from the loop's own visibility test: - -- a hidden row must not break a run, or toggling one row's hideOn would - -- silently change the spacing of the rows around it. - local layoutDB = DF.db[GUI.SelectedMode] - local function entryVisible(entry, index) - if self.collapsed and index > 1 then return false end - local w = entry and entry.widget - if not w then return false end - if w.hideOn and layoutDB and w.hideOn(layoutDB) then return false end - return true - end - - for i, entry in ipairs(self.groupChildren) do - local widget = entry.widget - local height = entry.height - - -- Close up a RUN of the same compact kind (see GUI.RowGapTight). The - -- reduction is taken off THIS row's slot, so it only ever affects the - -- gap to the row below -- and only when that row is the same compact - -- kind, which is what keeps the boundary between different kinds at - -- the full RowGap. - local kind = widget.rowKind - widget._rowTightened, widget._rowNextKind = false, nil - if kind and GUI.RowCompact[kind] then - for j = i + 1, #self.groupChildren do - if entryVisible(self.groupChildren[j], j) then - -- Recorded even when it does NOT match, so /df debug gapcheck can - -- say WHY a run did not close up: a row with no rowKind - -- sitting between two checkboxes breaks the run for the - -- layout while being invisible to the report (a widget - -- that draws nothing is skipped there), which would look - -- like the tightening was simply inert. - widget._rowNextKind = self.groupChildren[j].widget.rowKind or "" - if self.groupChildren[j].widget.rowKind == kind then - height = height - (GUI.RowGap - GUI.RowGapTight) - widget._rowTightened = true - end - break -- only the NEXT visible row decides - end - end - end - - -- If collapsed, only show the header (first widget) - if self.collapsed and i > 1 then - widget:Hide() - else - -- Check if widget should be visible - local shouldShow = true - if widget.hideOn then - local db = DF.db[GUI.SelectedMode] - if db and widget.hideOn(db) then - shouldShow = false - end - end - - if shouldShow then - widget:ClearAllPoints() - -- Snap y at USE, not as it accumulates: rounding each row height - -- in turn would let the error compound down a long column. - widget:SetPoint("TOPLEFT", self, "TOPLEFT", padding, SnapLen(self, y)) - -- Set width to fit within group padding (only once the group has one) - if canSize then widget:SetWidth(innerWidth) end - widget:Show() - y = y - height - visibleCount = visibleCount + 1 - else - widget:Hide() - end - end - end - - -- Show/hide collapsed summary and bottom collapse bar - if self.collapsible then - if self.collapsed then - if self.showSummary then - -- Build summary fontstring lazily on first use - if not self.collapseSummary then - self.collapseSummary = self:CreateFontString(nil, "OVERLAY") - DF:SafeSetFont(self.collapseSummary, nil, 9, "") - self.collapseSummary:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b, 0.5) - self.collapseSummary:SetJustifyH("LEFT") - self.collapseSummary:SetWordWrap(true) - end - - -- Collect labels from child widgets (skip header at index 1) - local labels = {} - for i = 2, #self.groupChildren do - local w = self.groupChildren[i].widget - -- Scan the widget's regions for a FontString with text - for _, region in ipairs({w:GetRegions()}) do - if region.GetText and region:GetText() and region:GetText() ~= "" then - labels[#labels + 1] = region:GetText() - break - end - end - end - - local summaryText = table.concat(labels, " \194\183 ") -- separated by · - self.collapseSummary:SetText(summaryText) - self.collapseSummary:ClearAllPoints() - self.collapseSummary:SetPoint("TOPLEFT", self, "TOPLEFT", padding, SnapLen(self, y)) - self.collapseSummary:SetWidth(innerWidth) - self.collapseSummary:Show() - -- Measure actual wrapped height - local summaryHeight = self.collapseSummary:GetStringHeight() or 12 - y = y - summaryHeight - 2 - else - if self.collapseSummary then self.collapseSummary:Hide() end - end - - if self.collapseBar then self.collapseBar:Hide() end - else - if self.collapseSummary then self.collapseSummary:Hide() end - if self.collapseBar then - self.collapseBar:Show() - y = y - self.collapseBar:GetHeight() - end - end - end - - -- Update group height (add padding at bottom) - -- The group's own height, snapped for the same reason its children's - -- widths are: this is what puts its TOP border on the grid. Since the - -- runtime geometry correction was removed, this IS the only thing that - -- does -- there is no after-the-fact pass to fall back on. - local totalHeight = SnapLen(self, math.abs(y) + padding) - if totalHeight < 1 then totalHeight = 1 end - self:SetHeight(totalHeight) - -- Add margin to calculated height for spacing between groups - self.calculatedHeight = totalHeight + self.margin - - return self.calculatedHeight - end - - -- Process disableOn for children - group.RefreshChildStates = function(self) - local db = DF.db[GUI.SelectedMode] - if not db then return end - - -- Group-level grey-out: set self.disableChildrenOn = function(db) ... end to - -- grey EVERY child when it returns true, EXCEPT the header and any widget - -- flagged widget.keepEnabled (the feature's own Enable toggle). Saves putting a - -- disableOn on every control; composes with per-widget disableOn (a child is - -- disabled if either says so). CreateCheckbox auto-calls RefreshStates on - -- toggle, so the grey state updates live. - local hasGroupGate = self.disableChildrenOn ~= nil - local groupOff = hasGroupGate and self.disableChildrenOn(db) or false - - for i, entry in ipairs(self.groupChildren) do - local widget = entry.widget - if widget.SetEnabled and (widget.disableOn or hasGroupGate) then - local shouldDisable = (widget.disableOn and widget.disableOn(db)) or false - if groupOff and i > 1 and not widget.keepEnabled then - shouldDisable = true - end - widget:SetEnabled(not shouldDisable) - end - if widget.refreshContent and widget:IsShown() then - widget:refreshContent(db) - end - end - end - - return group -end - -function GUI:CreateLabel(parent, text, width, color) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 380, 40) - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - -- Anchor both top corners so the wrap width tracks the frame's width. The - -- layout engine (settings-group LayoutChildren / page column sizing) resizes - -- the frame to the available width, so the text now uses the full width and - -- wraps when the window is narrow instead of overflowing/clipping at a fixed - -- width. Standalone (un-laid-out) labels keep the frame's initial `width`. - lbl:SetPoint("TOPLEFT", 0, -5) - lbl:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, -5) - lbl:SetJustifyH("LEFT") - lbl:SetWordWrap(true) - lbl:SetText(text) - - if color then - lbl:SetTextColor(color.r, color.g, color.b, color.a or 1) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 1) - end - - -- MEASURED slot height. A label is a variable-height widget, so ResolveRowHeight - -- prefers the call-site number and falls back to preferredHeight — stamping a - -- measured one here leaves every existing call site byte-identical while letting a - -- NEW one omit the number entirely and get a slot that fits the text however it - -- wraps. Hand-guessed numbers are exactly what let a 4-line blurb overlap the - -- dropdown beneath it (Colours page, Color by Time). - local function Remeasure() - local h = lbl:GetStringHeight() - if not h or h <= 0 then return false end - local newH = math.ceil(h) + (GUI.RowHeight.labelPad or 10) - if frame.preferredHeight == newH then return false end - frame.preferredHeight = newH - frame:SetHeight(newH) - return true - end - -- Force the FontString to re-flow at its CURRENT width. A dual-anchored string - -- resolves its wrap lazily, so one that was laid out before its frame reached - -- final width keeps the old single-line layout and renders ellipsised - -- ("Customize class colors used throughout DandersFra…") even though the frame - -- measures a correct 260 — /df debug guiwidth reports zero suspect frames while the - -- text is visibly truncated. Scrolling the settings window dirties it and the - -- text snaps back, which is the tell that it is a stale layout, not a bad size. - -- Clearing the text first matters: SetText with an unchanged string can early-out - -- without marking the string dirty. - local function Reflow() - local t = lbl:GetText() - if t and t ~= "" then - lbl:SetText("") - lbl:SetText(t) - end - end - -- The layout engine resizes this frame to the column's available width (see the - -- anchor note above), and GetStringHeight can return a stale single-line value until - -- the FontString has rendered at that final width — so converge ONCE on the next - -- frame, after LayoutChildren has run. Re-flow only when this label OWNS its slot: - -- inside a SettingsGroup (nothing else tracks a stored height) and with no call-site - -- number (_slotHeightExplicit, stamped by AddWidget) to override. Deliberately NOT an - -- OnSizeChanged binding — that cascade is the Aura Designer indicator-card lockup - -- documented on CreateInfoBanner; the cost is that a label added with no height does - -- not re-measure if its width changes again later. - local function Measure() - Remeasure() - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - if not frame:IsShown() then return end - -- Re-flow FIRST, and for EVERY label — the height converge below is - -- gated (it only runs for labels that own their slot), but a stale - -- wrap can strand any label, and the ones with a call-site height are - -- exactly the ones nothing else ever touches again. - Reflow() - if frame.settingsGroup and not frame._slotHeightExplicit and Remeasure() then - GUI:RelayoutHost(frame, frame.preferredHeight) - end - end) - end - end - Measure() - - -- A rebuilt page hides then re-shows its widgets, and a label re-shown at a width - -- it was not laid out at comes back with the stale single-line wrap. Re-flow on the - -- frame AFTER the show settles. Safe against the OnSizeChanged cascade that locked - -- up the AD indicator cards: Reflow re-applies the same string and never resizes, - -- so it cannot feed itself. - frame:SetScript("OnShow", function() - if C_Timer and C_Timer.After then C_Timer.After(0, Reflow) end - end) - - frame.SetText = function(self, newText) lbl:SetText(newText); Measure() end - return frame -end - --- CreateNote: a lightweight LEVELLED note (NO box — that is CreateInfoBanner's --- job) for an inline caveat/tip attached to a field or section. It is the middle --- tier between a plain CreateLabel and a full banner. --- opts.tone info | caution | danger | success — tints the note from the --- SAME palette as the banners (via ToneHex), so notes and banners --- speak one colour language. Omit for a neutral dim note. --- opts.prefix optional lead word ("Note", "Warning", "Recommendation") shown --- in the tone colour, followed by ": " and the body in dim text. --- opts.width wrap width. --- Returns a CreateLabel frame, so it is a drop-in anywhere a label goes. -function GUI:CreateNote(parent, text, opts) - opts = opts or {} - local str - if opts.tone and opts.prefix then - -- Route the prefix through L so "Note"/"Tip"/etc. are localizable (the - -- locale metatable returns the key unchanged when a locale lacks it). - local prefix = (L and L[opts.prefix]) or opts.prefix - str = "|c" .. self:ToneHex(opts.tone) .. prefix .. ":|r " .. text - elseif opts.tone then - str = "|c" .. self:ToneHex(opts.tone) .. text .. "|r" - else - str = text - end - return self:CreateLabel(parent, str, opts.width) -end - --- Shared link HOVER colour: the rest colour (the theme accent — blue in party, orange in --- raid) LIGHTENED toward white. Keeps the hue, so a hovered link brightens instead of going --- flat white and blending into white body text. One source of truth for every link's hover --- (SetHTML links, the page/URL link buttons, and hand-rolled note links). `c` = the link's --- rest colour (defaults to the live theme colour); returns {r,g,b}. -function GUI:LinkHoverColor(c) - c = c or (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 1, b = 1 } - local t = 0.45 -- lift toward white; tune here to re-key every link at once - return { r = c.r + (1 - c.r) * t, g = c.g + (1 - c.g) * t, b = c.b + (1 - c.b) * t } -end - --- ============================================================ --- CreateInfoBanner --- ------------------------------------------------------------ --- A self-resizing banner with an icon, body text, and a "tone" --- (info / caution / danger / success) that controls background, --- border, default text colour, and default icon. ("warning" is a --- legacy alias of "caution".) Do NOT pass fontTemplate — banners --- share one font on purpose; only the tone should vary. --- --- Usage: --- local banner = GUI:CreateInfoBanner(parent, { tone = "caution", text = "..." }) --- Add(banner, banner.layoutHeight, "both") --- --- Methods on the returned frame: --- :SetTone(name) apply a preset (see TONES below) --- :SetText(text, optColor) plain text mode, auto-wraps + auto-resizes --- :SetHTML(html, onLinkClick) flow-layout body with clickable link buttons --- :SetIcon(texture, r, g, b) icon texture + optional vertex colour --- :SetIconTexture(path) icon texture only --- :SetIconColor(r, g, b) icon vertex colour only --- --- The body word-wraps automatically; banner height is recomputed via --- OnSizeChanged so resizing the GUI (or calling SetText/SetHTML) grows --- or shrinks the banner to fit. The host page is re-laid out so widgets --- below the banner reposition. --- ============================================================ --- Each tone carries FOUR colour roles so all three consumers (banners, --- inline ToneHex text, and tooltips) stay in sync: --- bg / border / textColor / icon+iconColor drive the BANNER box itself. --- accent is the vivid emphasis colour --- used for INLINE text (ToneHex) and tooltip titles, read against the --- dark GUI background rather than the banner's own tinted bg. It is a --- SEPARATE role from iconColor: e.g. the danger icon is a light warm so --- the triangle pops on the orange banner, but inline "Warning" text must --- be a real red to out-rank a caution — deriving one from the other --- (the original bug) made inline danger paler than caution. -local INFO_BANNER_TONES = { - info = { - bg = {0.15, 0.18, 0.28, 1}, - useThemeBorder = true, borderAlpha = 0.5, - icon = "info", - textColor = {0.85, 0.85, 0.85}, - accent = {0.6, 0.8, 1}, -- light blue - }, - -- NOTE: "warning" was merged into "caution" (they were near-duplicate golds). - -- SetTone("warning") still resolves via the alias below for safety. - caution = { - bg = {0.5, 0.45, 0.1, 0.9}, - border = {0.7, 0.6, 0.1, 1}, - icon = "warning", iconColor = {1, 0.9, 0.3}, - textColor = {1, 0.95, 0.7}, - accent = {1, 0.82, 0}, -- gold - }, - danger = { - bg = {0.6, 0.3, 0.1, 0.9}, - border = {0.8, 0.4, 0.1, 1}, - -- icon kept a light warm (not the mid-orange bg hue) so the triangle pops - icon = "warning", iconColor = {1, 0.9, 0.72}, - textColor = {1, 0.85, 0.7}, - accent = {1, 0.27, 0.27}, -- real red (destructive), NOT the pale icon warm - }, - success = { - bg = {0.1, 0.4, 0.2, 0.9}, - border = {0.2, 0.6, 0.3, 1}, - icon = "check", iconColor = {0.3, 1, 0.5}, - textColor = {0.7, 1, 0.8}, - accent = {0.4, 0.85, 0.5}, -- green - }, -} --- Legacy alias: "warning" was merged into "caution" (near-duplicate golds). -INFO_BANNER_TONES.warning = INFO_BANNER_TONES.caution -P.INFO_BANNER_TONES = INFO_BANNER_TONES - --- Hex accent ("ffRRGGBB", for inline |c...|r escapes) matching a banner tone, so --- inline caveat text (e.g. a warning word in a subtitle) reads as the SAME --- info/caution/danger/success language as the banners instead of an ad-hoc colour. --- Uses the tone's dedicated inline `accent` (NOT the banner iconColor, which is --- tuned to sit on the banner's own bg and would make danger paler than caution). --- The {r, g, b} behind a tone name, for callers that set a colour directly --- rather than embedding inline markup. Same resolution order as ToneHex, so a --- toned title and toned inline text always match. -function GUI:GetToneColor(toneName) - local t = INFO_BANNER_TONES[toneName] or INFO_BANNER_TONES.caution - return t.accent or t.iconColor or t.textColor or {1, 1, 1} -end - -function GUI:ToneHex(toneName) - local t = INFO_BANNER_TONES[toneName] or INFO_BANNER_TONES.caution - local c = t.accent or t.iconColor or t.textColor or {1, 1, 1} - return string.format("ff%02x%02x%02x", - math.floor((c[1] or 1) * 255 + 0.5), - math.floor((c[2] or 1) * 255 + 0.5), - math.floor((c[3] or 1) * 255 + 0.5)) -end - -local INFO_BANNER_ICON_PATH = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - --- Shared inline-markup parser: split "…|cCOLOR|HlinkData|hText|h|r…" (WoW hyperlink markup --- plus \n line breaks) into a flat token list of { type = "word"/"link"/"newline", text, data, --- color }. Used by the InfoBanner's SetHTML flow AND GUI:CreateLink, so both read links the --- same way — one parser, no fork. -local function ParseHTMLSegments(s) - local segs = {} - local function addWords(chunk) - local pos = 1 - while pos <= #chunk do - local nl = chunk:find("\n", pos, true) - local line = nl and chunk:sub(pos, nl - 1) or chunk:sub(pos) - for _, w in ipairs({ strsplit(" ", line) }) do - if #w > 0 then segs[#segs + 1] = { type = "word", text = w } end - end - if nl then - segs[#segs + 1] = { type = "newline" } - pos = nl + 1 - else - break - end - end - end - local rem = s - while #rem > 0 do - local pre, color, data, lt, rest = - rem:match("^(.-)|c(%x%x%x%x%x%x%x%x)|H([^|]*)|h([^|]*)|h|r(.*)") - if pre ~= nil then - addWords(pre) - segs[#segs + 1] = { type = "link", text = lt, data = data, color = color } - rem = rest or "" - else - addWords(rem) - break - end - end - return segs -end - --- FlowSpaceWidth — the font's OWN space advance, so a word-per-FontString flow --- (CreateLink / InfoBanner) spaces exactly like a single wrapped FontString. A --- fixed pixel gap reads too loose at small sizes; measuring "m m" minus "mm" --- isolates one space advance. `sizePx` set → measure the user's settings font at --- that px (matches a banner word, which is SetSettingsFont'd); nil → measure the --- template's own font object (a CreateLink / template-fonted word). One reused --- probe (no per-call FontString churn); measured fresh so a font-family change is --- always reflected. Returns the EXACT fractional advance (no pixel rounding) so the --- flow spaces identically to native wrapped text — see the return note below. -local function FlowSpaceWidth(tmpl, sizePx) - tmpl = tmpl or "DFFontHighlightSmall" - if not S._flowProbe then - S._flowProbe = UIParent:CreateFontString(nil, "OVERLAY") - end - if sizePx and DF.SafeSetFont then - local fontName = (DF.db and DF.db.settingsFont) or "DF Roboto SemiBold" - DF:SafeSetFont(S._flowProbe, fontName, sizePx, "") - else - S._flowProbe:SetFontObject(_G[tmpl] or _G.GameFontHighlight) - end - -- Average over N spaces: each GetStringWidth is pixel-rounded, so a single-space - -- "m m" - "mm" diff can inflate the space advance by ~1px (which read as too-loose - -- word gaps next to the native-wrapped notes). Isolating N spaces and dividing - -- shrinks that rounding error to ~1/N of a pixel, so the flow spaces like real text. - local N = 12 - S._flowProbe:SetText("m" .. string.rep(" m", N)); local wA = S._flowProbe:GetStringWidth() - S._flowProbe:SetText("m" .. string.rep("m", N)); local wB = S._flowProbe:GetStringWidth() - S._flowProbe:SetText("") - local sp = (wA - wB) / N - if not sp or sp <= 0 then sp = 3 end - -- Return the EXACT fractional advance, NOT math.floor(sp+0.5). Rounding a small - -- space (Roboto ~2.7px at 11px) UP to a whole pixel added a fixed sliver to every - -- word gap, so the flow read looser than a native wrapped FontString — which - -- positions its own spaces at sub-pixel offsets. Fractional here = same gap as - -- native. (Krathe: "look like normal text with links, no extra spacing.") - return sp -end - --- ============================================================ --- DISABLED OVERLAY — the "this feature is switched off" scrim. --- --- A dimming plate over the part of a page you cannot act on yet, carrying the --- feature's name and a pointer at the toggle that turns it on. EnableMouse is --- the working half: greying a control says "not now", but a page of buttons --- that still FUNCTION while the feature is off reads as though it were on, and --- the user builds a thing that silently does nothing. --- --- The caller anchors it, because the extent is a per-page judgement — cover --- what can be acted on, not necessarily everything below the toggle. Explaining --- content (a "how it works" box) is worth leaving readable; a user staring at --- the scrim is exactly the one who still needs it. --- --- opts.label the big line, e.g. L["Aura Designer is disabled"] --- opts.sublabel the small line (defaults to the shared "Enable the checkbox --- above to use") --- opts.level frame-level bump over the parent (default 50) --- --- Returns the frame; drive it with :SetShown(not enabled) from wherever the --- flag changes. --- ============================================================ -function GUI:CreateDisabledOverlay(parent, opts) - opts = opts or {} - local overlay = CreateFrame("Frame", nil, parent, "BackdropTemplate") - overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + (opts.level or 50)) - overlay:EnableMouse(true) - - local bg = overlay:CreateTexture(nil, "BACKGROUND") - bg:SetAllPoints() - bg:SetColorTexture(C_BACKGROUND.r, C_BACKGROUND.g, C_BACKGROUND.b, 0.85) - - local label = overlay:CreateFontString(nil, "OVERLAY", "DFFontNormal") - label:SetPoint("CENTER", 0, 10) - label:SetText(opts.label or "") - label:SetTextColor(0.6, 0.6, 0.6, 1) - overlay.Label = label - - local sub = overlay:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - sub:SetPoint("TOP", label, "BOTTOM", 0, -4) - sub:SetText(opts.sublabel or L["Enable the checkbox above to use"]) - sub:SetTextColor(0.45, 0.45, 0.45, 1) - overlay.SubLabel = sub - - return overlay -end - -function GUI:CreateInfoBanner(parent, opts) - opts = opts or {} - - local banner = CreateFrame("Frame", nil, parent, "BackdropTemplate") - -- SetTone overwrites both colours, and opts.tone is applied at the bottom of - -- this function -- so these defaults only show on a tone-less banner, which - -- previously drew an untinted (white) box because nothing coloured it. - CreateElementBackdrop(banner) - -- Give the banner a defined initial height so child frames have valid positions - -- from the very first frame (before DoRecomputeHeight has run). - banner:SetHeight(opts.minHeight or 34) - - -- Icon: top-left anchored so it stays put when content wraps to multiple lines. - local icon = banner:CreateTexture(nil, "OVERLAY") - icon:SetPoint("TOPLEFT", 12, -10) - icon:SetSize(22, 22) - banner.icon = icon - - -- Plain-text body. Anchored top + right (no bottom) so the FontString - -- auto-grows to its natural wrapped height; the banner then resizes - -- to fit it via RecomputeHeight. SetWordWrap is on so long text wraps - -- at the width defined by the LEFT/RIGHT anchors. - local fontTemplate = opts.fontTemplate or "DFFontHighlight" - local body = banner:CreateFontString(nil, "OVERLAY", fontTemplate) - if not opts.fontTemplate then - -- Default body a touch below DFFontHighlight (12px) — 11px reads cleaner - -- in the banner while staying bigger than the old Small (10px). Icon stays 22. - GUI:SetSettingsFont(body, 11, "") - end - -- Y offset centres the first line on the icon (body 11px, icon 22px). The - -- text sits a few px below the icon's top so its centre lines up with the - -- icon's centre. - body:SetPoint("TOPLEFT", icon, "TOPRIGHT", 8, -5) - body:SetPoint("RIGHT", banner, "RIGHT", -12, 0) - body:SetJustifyH("LEFT") - body:SetJustifyV("TOP") - body:SetWordWrap(true) - body:SetNonSpaceWrap(true) - if body.SetMaxLines then body:SetMaxLines(0) end - banner.body = body - - banner.layoutHeight = (opts.minHeight or 28) + 6 - - local cachedH, recomputing = nil, false - - -- Sync the banner's measured slot height into its host group and re-flow (bubbling - -- to the page so sibling groups re-anchor). Shared with the measured label — see - -- GUI:RelayoutHost, which is this logic verbatim; the page bubble is what stops a - -- grown group's backdrop overshooting the next group's anchor when an animation - -- type is first selected in a border panel. - local function TriggerHostRelayout() - GUI:RelayoutHost(banner, banner.layoutHeight) - end - - local function MeasureContent() - if banner._isHTML then - -- For HTML mode the flow layout positions all widgets and returns - -- the total pixel height of all lines. Re-running it here keeps - -- positions fresh and gives us an accurate height in one step. - return math.max(18, banner._DoFlowLayout and banner._DoFlowLayout() or 18) - end - return math.max(18, body:GetStringHeight()) - end - - local pending = false - -- Set whenever a RecomputeHeight() request was deferred because the - -- banner was invisible. Cleared once a real recompute runs after the - -- banner becomes visible. OnShow checks this flag to decide whether to - -- trigger a fresh recompute when the widget surfaces. - local deferredWhileHidden = false - local function DoRecomputeHeight() - pending = false - if recomputing then return end - -- Skip when the banner is hidden — GetStringHeight on a hidden - -- FontString returns an unreliable value (width depends on the - -- parent's layout having run, and LayoutChildren doesn't SetWidth - -- on hidden widgets), and the resulting SetHeight + Trigger­Host­ - -- Relayout cascade costs real work proportional to the host - -- SettingsGroup's widget count. For consumers that mount banners - -- behind hideOn predicates that default to true (animation perf - -- warning at type=NONE) this used to fire one cascade per banner - -- at every GUI open — N indicator cards × ~25-widget group × - -- proxy-backed dbTable in Aura Designer = sustained lockup. - if not banner:IsVisible() then - deferredWhileHidden = true - return - end - local h = math.ceil(MeasureContent()) - -- Chrome: 13 px top (icon at -10, text nudged -3) + 9 px bottom = 22 px. - -- Snapped to whole device pixels so the banner's TOP border lands on the - -- grid. Done HERE rather than by opting into the generic size snapper, - -- because this function owns the height and is the thing the cascade - -- documented above runs through -- snapping the number before cachedH - -- sees it keeps the existing "did it actually change?" guard authoritative - -- instead of adding a second writer behind its back. - local newH = SnapLen(banner, math.max(opts.minHeight or 28, h + 22)) - if cachedH ~= newH then - cachedH = newH - recomputing = true - banner:SetHeight(newH) - banner.layoutHeight = newH + 6 - TriggerHostRelayout() - recomputing = false - end - -- Schedule one more measurement next frame: GetStringHeight can - -- return a stale single-line value the first time it's read after - -- a width change, before the FontString has finished re-rendering. - -- A second pass converges to the true wrapped height. - if not banner._secondPassDone then - banner._secondPassDone = true - if C_Timer and C_Timer.After then - C_Timer.After(0, DoRecomputeHeight) - end - end - end - - -- Defer measurement to next frame so FontString has rendered with its - -- current width — GetStringHeight can return a stale single-line value - -- if called immediately after a width change. Coalesce multiple calls - -- per frame via the `pending` flag. - local function RecomputeHeight() - banner._secondPassDone = false -- allow follow-up pass on every fresh trigger - if pending then return end - pending = true - if C_Timer and C_Timer.After then - C_Timer.After(0, DoRecomputeHeight) - else - DoRecomputeHeight() - end - end - - -- opts.staticHeight: skip ALL recompute machinery (no OnSizeChanged - -- binding, no OnShow re-measure, no DoRecomputeHeight cascade). - -- For consumers whose text never changes after construction AND who - -- can predict a sensible fixed height up front (e.g. animation perf - -- warning). Avoids the SetHeight → OnSizeChanged → TriggerHostRelayout - -- → g:LayoutChildren feedback loop that, in container layouts where - -- LayoutChildren re-fires SetWidth on every pass (Aura Designer's - -- indicator card body), drops FPS the moment the banner surfaces. - if not opts.staticHeight then - -- Only width changes affect the wrapped string height — height - -- changes (which our own SetHeight inside DoRecomputeHeight triggers) - -- don't. Filtering on width breaks part of the feedback loop, but - -- doesn't help when the host layout fires OnSizeChanged per frame - -- with same-or-different widths (some scroll-frame containers do). - local lastMeasuredWidth - banner:SetScript("OnSizeChanged", function(self, w, _) - if w == lastMeasuredWidth then return end - lastMeasuredWidth = w - RecomputeHeight() - end) - -- HookScript, not SetScript: CreateElementBackdrop already hooked OnShow, - -- to re-derive this frame's border thickness if the UI scale changed - -- while it was hidden, and a SetScript here would throw that hook away. - -- Nothing else would re-derive it -- a banner that appears late is - -- exactly the case that hook exists for. - banner:HookScript("OnShow", function() - if deferredWhileHidden then - deferredWhileHidden = false - cachedH = nil - lastMeasuredWidth = nil - RecomputeHeight() - end - end) - end - banner._RecomputeHeight = RecomputeHeight - - function banner:SetIconTexture(path) - self.icon:SetTexture(path) - end - - function banner:SetIconColor(r, g, b) - self.icon:SetVertexColor(r or 1, g or 1, b or 1) - end - - function banner:SetIcon(path, r, g, b) - self:SetIconTexture(path) - if r then self:SetIconColor(r, g, b) end - end - - function banner:SetTone(toneName) - local tone = INFO_BANNER_TONES[toneName] - if not tone then return end - self._tone = toneName - if tone.bg then self:SetBackdropColor(tone.bg[1], tone.bg[2], tone.bg[3], tone.bg[4] or 1) end - if tone.useThemeBorder then - local tc = (GUI.GetThemeColor and GUI.GetThemeColor()) or {r = 1, g = 1, b = 1} - self:SetBackdropBorderColor(tc.r, tc.g, tc.b, tone.borderAlpha or 1) - elseif tone.border then - self:SetBackdropBorderColor(tone.border[1], tone.border[2], tone.border[3], tone.border[4] or 1) - end - if tone.icon then - self:SetIconTexture(INFO_BANNER_ICON_PATH .. tone.icon) - end - if tone.iconColor then - self:SetIconColor(tone.iconColor[1], tone.iconColor[2], tone.iconColor[3]) - else - self:SetIconColor(1, 1, 1) - end - if tone.textColor then - self.body:SetTextColor(tone.textColor[1], tone.textColor[2], tone.textColor[3]) - end - end - - function banner:SetText(text, color) - text = text or "" - -- Idempotent guard (same freeze class as SetHTML): when already showing this - -- exact plain text, skip the cachedH reset + RecomputeHeight + host relayout - -- so a refreshContent-driven SetText can't loop. Colour is cheap to re-apply - -- without a recompute. (SetContent bakes its themed title into `text`, so a - -- mode switch changes the string and correctly re-renders.) - if not self._isHTML and self._plainText == text then - if color then - local r = color[1] or color.r - local g = color[2] or color.g - local b = color[3] or color.b - if r then self.body:SetTextColor(r, g, b) end - end - return - end - self._plainText = text - -- Hide any flow widgets from a previous SetHTML call. - if self._flowWidgets then - for _, w in ipairs(self._flowWidgets) do w:Hide() end - end - self._isHTML = false - self.body:Show() - self.body:SetText(text) - if color then - local r = color[1] or color.r - local g = color[2] or color.g - local b = color[3] or color.b - if r then self.body:SetTextColor(r, g, b) end - end - cachedH = nil - banner._secondPassDone = false - RecomputeHeight() - end - - -- Theme-coloured "Title: body" content with a live-updating title colour + - -- theme border (folds in the old CreateInfoCallout). Registers the banner as - -- a ThemeListener so the title/border re-colour on party/raid mode switch. - function banner:SetContent(title, body) - self._contentTitle, self._contentBody = title, body - if title and title ~= "" then - local tc = (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 1, b = 1 } - local hex = string.format("ff%02x%02x%02x", - math.floor(tc.r * 255), math.floor(tc.g * 255), math.floor(tc.b * 255)) - self:SetText("|c" .. hex .. title .. ":|r " .. (body or "")) - else - self:SetText(body or "") - end - if not self._themeRegistered then - self._themeRegistered = true - local p = self:GetParent() - if p then - p.ThemeListeners = p.ThemeListeners or {} - table.insert(p.ThemeListeners, self) - end - end - end - - function banner:UpdateTheme() - if self._tone then self:SetTone(self._tone) end - if self._contentTitle ~= nil or self._contentBody ~= nil then - self:SetContent(self._contentTitle, self._contentBody) - end - end - - -- SetHTML renders text + clickable links using real Button widgets in a - -- flow layout. This mirrors the original per-link-button approach that - -- reliably dispatches OnClick in WoW, unlike SimpleHTML whose - -- OnHyperlinkClick failed to fire consistently. - -- - -- Input text uses WoW hyperlink markup: |cCOLOR|HlinkData|hText|h|r - -- and \n for explicit line breaks. Plain text is word-split so wrapping - -- occurs at word boundaries when the banner is narrow. - - -- (Markup parsing is the file-level ParseHTMLSegments, shared with GUI:CreateLink.) - - -- Position all flow widgets left-to-right with wrapping; returns total - -- content height. Punctuation tokens attach to the preceding element - -- with no leading gap so "Foo," renders without extra space before the comma. - local FLOW_LINE_H = 14 - -- Banner words are SetSettingsFont'd to 11px unless a custom template is given; measure the - -- space advance at that same font so the flow spaces like native text (see FlowSpaceWidth). - local flowSpaceW = FlowSpaceWidth(fontTemplate, (not opts.fontTemplate) and 11 or nil) - local function DoFlowLayout() - if not banner._flowSegs then return 0 end - local availW = banner:GetWidth() - (12 + 18 + 8) - 12 - if availW < 20 then return FLOW_LINE_H end - local x, lineY = 0, -3 - for _, seg in ipairs(banner._flowSegs) do - if seg.type == "newline" then - x = 0; lineY = lineY - FLOW_LINE_H - 2 - elseif seg._widget then - local w = seg._w - -- Only a token that is ENTIRELY trailing punctuation (a lone "." or "," after a - -- link) hugs the preceding word. Connectors like & / - are whole words and keep - -- normal spacing on both sides — else "Texture & Colors" renders as "Texture& …". - local isPunct = seg.type == "word" and seg.text:match("^[%.%,%;%:%!%?%)%]%}]+$") and true or false - local gap = (x > 0 and not isPunct) and flowSpaceW or 0 - if x > 0 and (x + gap + w) > availW then - x = 0; lineY = lineY - FLOW_LINE_H - 2; gap = 0 - end - seg._widget:ClearAllPoints() - seg._widget:SetPoint("TOPLEFT", icon, "TOPRIGHT", 8 + x + gap, lineY) - x = x + gap + w - end - end - return math.abs(lineY - (-3)) + FLOW_LINE_H - end - banner._DoFlowLayout = DoFlowLayout - - function banner:SetHTML(text, onLinkClick) - text = text or "" - -- The link words are tinted with the theme colour at render time (below), - -- NOT baked into `text`, so a party/raid mode switch must re-render even - -- when the text is identical — fold the theme into the dedupe key. - local _tc = GUI.GetThemeColor and GUI.GetThemeColor() or {r = 1, g = 0.82, b = 0} - local themeKey = string.format("%.3f,%.3f,%.3f", _tc.r or 1, _tc.g or 1, _tc.b or 1) - -- Idempotent guard (FREEZE FIX): tearing down + rebuilding the flow widgets - -- resets cachedH and re-fires RecomputeHeight -> TriggerHostRelayout -> - -- host:RefreshStates. This banner's SetHTML is driven from a refreshContent - -- hook that RefreshStates calls on EVERY pass, so an unguarded rebuild loops - -- forever (game freeze — seen on the Buffs page when the Aura Designer banner - -- is shown). Skip the rebuild when neither the text nor the link-tint theme - -- changed; just keep the click handler current. - if self._isHTML and self._htmlText == text and self._htmlThemeKey == themeKey then - self._onLinkClick = onLinkClick - return - end - self._htmlThemeKey = themeKey - self._htmlText = text - self._onLinkClick = onLinkClick - self._isHTML = true - self.body:Hide() - - -- Tear down widgets from any previous call. - if self._flowWidgets then - for _, w in ipairs(self._flowWidgets) do w:Hide() end - end - self._flowWidgets = {} - - local tc = GUI.GetThemeColor and GUI.GetThemeColor() or {r = 1, g = 0.82, b = 0} - local segs = ParseHTMLSegments(self._htmlText) - self._flowSegs = segs - - for _, seg in ipairs(segs) do - if seg.type == "word" then - local fs = self:CreateFontString(nil, "OVERLAY", fontTemplate) - if not opts.fontTemplate then GUI:SetSettingsFont(fs, 11, "") end -- match the 11px plain body - fs:SetText(seg.text) - fs:SetTextColor(0.85, 0.85, 0.85) - seg._w = fs:GetStringWidth() - -- Give an explicit size matching the button height so TOPLEFT - -- anchors place both text words and link buttons on the same baseline. - fs:SetSize(seg._w, FLOW_LINE_H) - seg._widget = fs - self._flowWidgets[#self._flowWidgets + 1] = fs - elseif seg.type == "link" then - local btn = CreateFrame("Button", nil, self) - local fs = btn:CreateFontString(nil, "OVERLAY", fontTemplate) - if not opts.fontTemplate then GUI:SetSettingsFont(fs, 11, "") end -- match the 11px plain body - fs:SetAllPoints() - fs:SetJustifyH("LEFT") -- ink flush-left so the link spaces like a plain word - fs:SetText(seg.text) - fs:SetTextColor(tc.r, tc.g, tc.b) - -- Box width ceil'd (anti last-glyph clip), but the flow ADVANCE uses the - -- RAW width — else the ≤1px of empty box after every link became extra - -- gap before the next word (looser than native). seg._w drives the gap. - local rawW = fs:GetStringWidth() - btn:SetSize(math.ceil(rawW), FLOW_LINE_H) - btn:SetScript("OnEnter", function() - local h = GUI:LinkHoverColor((GUI.GetThemeColor and GUI.GetThemeColor()) or tc) - fs:SetTextColor(h.r, h.g, h.b) - end) - btn:SetScript("OnLeave", function() - local c = GUI.GetThemeColor and GUI.GetThemeColor() or tc - fs:SetTextColor(c.r, c.g, c.b) - end) - local segData = seg.data - btn:SetScript("OnClick", function() - if self._onLinkClick then - local _, pageId = strsplit(":", segData) - self._onLinkClick(pageId or segData) - end - end) - seg._widget = btn - seg._w = rawW - self._flowWidgets[#self._flowWidgets + 1] = btn - end - end - - DoFlowLayout() - cachedH = nil - banner._secondPassDone = false - RecomputeHeight() - end - - -- Apply opts at creation - if opts.tone then banner:SetTone(opts.tone) end - if opts.iconTexture then banner:SetIconTexture(opts.iconTexture) end - if opts.iconColor then banner:SetIconColor(opts.iconColor[1], opts.iconColor[2], opts.iconColor[3]) end - if opts.html then - banner:SetHTML(opts.text, opts.onLinkClick) - elseif opts.text then - banner:SetText(opts.text, opts.textColor) - end - - return banner -end - --- ============================================================ --- GUI:CreateLink — lean inline text + clickable links in a NOTE style (no box), FIXED layout. --- The link-capable counterpart to CreateNote: same |cCOLOR|Hdata|hText|h|r markup as the --- InfoBanner (shared ParseHTMLSegments), rendered as flowing dim body text with a themed, --- hover-lightening Button per link — but WITHOUT the banner's self-resize machinery, so it is --- safe inside the Aura Designer's reflowing indicator cards (no OnSizeChanged -> relayout loop --- that drops FPS there). Only the link words are clickable/hovered (fixes the old note's --- whole-frame click). Named CreateLink (not CreateLinkText) so we can grow other link forms. --- --- opts: --- onLinkClick(data) called with the link's raw data string on click. --- width wrap width; if given, flows immediately. Omit to flow once when the host --- first sizes the frame (then it stops — no re-flow loop). --- fontTemplate body font (default DFFontHighlightSmall — the note look). --- lineHeight per-line height (default 14). --- padTop/padBottom vertical breathing room baked into layoutHeight (default 2 / 8) so a note --- isn't glued to the controls above/below it — the measured height (and thus --- the slot a caller gives it) already includes it. More below than above so --- the note reads as annotating the control above while clearing the next one. --- Returns the frame; frame.layoutHeight is the measured height after flow; frame:Reflow(w) --- re-flows at a new width if a caller ever needs it. --- ============================================================ -function GUI:CreateLink(parent, text, opts) - opts = opts or {} - local onLinkClick = opts.onLinkClick - local fontTemplate = opts.fontTemplate or "DFFontHighlightSmall" - local LINE_H = opts.lineHeight or 14 - local PAD_TOP = opts.padTop or 2 - local PAD_BOTTOM = opts.padBottom or 8 - local frame = CreateFrame("Frame", nil, parent) - frame:SetHeight(LINE_H) - - local segs = ParseHTMLSegments(text or "") - local tc = (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 0.82, b = 0 } - - for _, seg in ipairs(segs) do - if seg.type == "word" then - local fs = frame:CreateFontString(nil, "OVERLAY", fontTemplate) - fs:SetText(seg.text) - fs:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) -- dim body, like a note - seg._w = fs:GetStringWidth() - fs:SetSize(seg._w, LINE_H) - seg._widget = fs - elseif seg.type == "link" then - local btn = CreateFrame("Button", nil, frame) - local fs = btn:CreateFontString(nil, "OVERLAY", fontTemplate) - fs:SetAllPoints() - fs:SetJustifyH("LEFT") -- ink flush-left so the link spaces like a plain word - fs:SetText(seg.text) - fs:SetTextColor(tc.r, tc.g, tc.b) - -- Box ceil'd (anti last-glyph clip); flow ADVANCE uses the RAW width so the - -- ≤1px of empty box after a link doesn't become extra word gap (see SetHTML). - local rawW = fs:GetStringWidth() - btn:SetSize(math.ceil(rawW), LINE_H) - btn:SetScript("OnEnter", function() - local h = GUI:LinkHoverColor((GUI.GetThemeColor and GUI.GetThemeColor()) or tc) - fs:SetTextColor(h.r, h.g, h.b) - end) - btn:SetScript("OnLeave", function() - local c = (GUI.GetThemeColor and GUI.GetThemeColor()) or tc - fs:SetTextColor(c.r, c.g, c.b) - end) - local segData = seg.data - btn:SetScript("OnClick", function() if onLinkClick then onLinkClick(segData) end end) - seg._w = rawW - seg._widget = btn - end - end - - -- Match native inter-word spacing: the flow gap is the font's own space advance, so a - -- word-per-FontString line reads exactly like a single wrapped FontString (a fixed pixel - -- gap looks too loose at small sizes). Words here use the raw template font (no - -- SetSettingsFont resize), so measure the template object directly. - local SPACE_W = FlowSpaceWidth(fontTemplate) - - -- Wrap the tokens left-to-right at `w`; punctuation hugs the preceding token. Sets the - -- frame height to fit. Fixed layout — never re-flows on its own, so no host feedback loop. - local function doFlow(w) - w = w or frame:GetWidth() or 0 - if w < 20 then return LINE_H end - local x, lineY = 0, -PAD_TOP -- start below the top so the first line isn't glued up - for _, seg in ipairs(segs) do - if seg.type == "newline" then - x = 0; lineY = lineY - LINE_H - 2 - elseif seg._widget then - -- Only a token that is ENTIRELY trailing punctuation (a lone "." or "," after a - -- link) hugs the preceding word. Connectors like & / - are whole words and keep - -- normal spacing on both sides — else "Texture & Colors" renders as "Texture& …". - local isPunct = seg.type == "word" and seg.text:match("^[%.%,%;%:%!%?%)%]%}]+$") and true or false - local gap = (x > 0 and not isPunct) and SPACE_W or 0 - if x > 0 and (x + gap + seg._w) > w then - x = 0; lineY = lineY - LINE_H - 2; gap = 0 - end - seg._widget:ClearAllPoints() - seg._widget:SetPoint("TOPLEFT", frame, "TOPLEFT", x + gap, lineY) - x = x + gap + seg._w - end - end - local h = math.abs(lineY) + LINE_H + PAD_BOTTOM - frame:SetHeight(h); frame.layoutHeight = h - return h - end - frame.Reflow = function(_, w) return doFlow(w) end - - if opts.width then - frame:SetWidth(opts.width) - doFlow(opts.width) - else - frame:SetScript("OnSizeChanged", function(self, w) - if w and w > 20 and not self._flowed then - self._flowed = true - self:SetScript("OnSizeChanged", nil) -- flow once; never re-flow (no loop) - doFlow(w) - end - end) - end - return frame -end - --- GUI:FlashWidget — the "show me" pulse (revived from the pre-12.1 boss-debuffs jump): briefly --- highlight a widget/section in the theme colour so the eye lands on it after a jump. One reused --- overlay per target; the colour refreshes each call (party blue / raid orange). --- opts (all opt-in / out): --- fill (default true) — a soft theme-coloured WASH (peaks ~35% alpha, control stays legible). --- border (default false) — a theme-coloured OUTLINE. Mix per call: a whole section reads well --- as border-only; a single control as fill + border. --- alpha fill peak alpha (default 0.35). borderSize outline thickness px (default 2). --- The overlay is a backdrop frame parented to the target's parent + anchored to the target, so --- it works whether the target is a Frame or a raw FontString (section headers). -function GUI:FlashWidget(widget, opts) - if not widget or not widget.GetParent then return end - opts = opts or {} - local doFill = opts.fill ~= false - local doBorder = opts.border and true or false - if not doFill and not doBorder then doFill = true end -- something has to show - local hl = widget._dfFlashHL - if not hl then - local host = widget:GetParent() or widget - hl = CreateFrame("Frame", nil, host, "BackdropTemplate") - hl:SetPoint("TOPLEFT", widget, "TOPLEFT", -3, 3) - hl:SetPoint("BOTTOMRIGHT", widget, "BOTTOMRIGHT", 3, -3) - local wl = (widget.GetFrameLevel and widget:GetFrameLevel()) - or (host.GetFrameLevel and host:GetFrameLevel()) or 1 - hl:SetFrameLevel(wl + 4) -- draw over the target - widget._dfFlashHL = hl - end - local c = (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 0.82, b = 0 } - -- Re-issued per call so the pulse picks up the current theme colour. - CreateElementBackdrop(hl, { - fill = doFill, - outline = doBorder, - edgeSize = opts.borderSize or 2, - bgColor = { c.r, c.g, c.b, opts.alpha or 0.35 }, - borderColor = { c.r, c.g, c.b, 1 }, - }) - - -- Gentle alpha pulse (mirrors the live "show me" highlight): a few soft - -- fade in/out cycles then a slow fade to nothing — a calm breathe rather - -- than a hard flash. The group drives hl's frame alpha; the backdrop keeps - -- its own tint alpha, so the two multiply into a subtle pulse. - local pulse = hl._dfPulse - if not pulse then - pulse = hl:CreateAnimationGroup() - local PULSES, HALF = 4, 0.4 - for i = 1, PULSES do - local up = pulse:CreateAnimation("Alpha") - up:SetFromAlpha(0.3); up:SetToAlpha(1) - up:SetDuration(HALF); up:SetOrder(i * 2 - 1) - local down = pulse:CreateAnimation("Alpha") - down:SetFromAlpha(1); down:SetToAlpha(0.3) - down:SetDuration(HALF); down:SetOrder(i * 2) - end - local out = pulse:CreateAnimation("Alpha") - out:SetFromAlpha(0.3); out:SetToAlpha(0) - out:SetDuration(0.6); out:SetOrder(PULSES * 2 + 1) - pulse:SetScript("OnFinished", function() hl:SetAlpha(0); hl:Hide() end) - hl._dfPulse = pulse - end - pulse:Stop() - hl:SetAlpha(1) - hl:Show() - pulse:Play() -end - --- GUI:LinkToSetting — the click action for a settings-link: jump to a setting and flash it. --- Unifies same-page and cross-page so every link behaves identically. target: --- page tab to switch to first (nil = stay on the current page). --- section section header text — scrolls to it (Search:ScrollToSection) and flashes it. --- widget explicit widget to flash (overrides the section-header lookup). --- scrollTo optional function() that scrolls a CUSTOM container (e.g. an Aura Designer card) --- to the target — used instead of the page section scroll. --- flash false = no pulse; a table = FlashWidget opts (fill / border / …) so a link picks --- its own highlight style; nil or true = the default flash. -function GUI:LinkToSetting(target) - if type(target) ~= "table" then return end - local function go() - local w = target.widget - if target.scrollTo then - target.scrollTo() - elseif target.page and target.section and DF.Search and DF.Search.ScrollToSection then - w = DF.Search:ScrollToSection(target.page, target.section) or w - end - if w and target.flash ~= false then - local fopts = type(target.flash) == "table" and target.flash or nil - C_Timer.After(0.05, function() GUI:FlashWidget(w, fopts) end) -- after the scroll settles - end - end - if target.page and GUI.SelectTab then - GUI.SelectTab(target.page) - C_Timer.After(0.12, go) -- let the tab build + lay out before scroll/flash - else - go() - end -end - --- GUI:CreateColorsPageLink — the shared "Customize duration colors on the Colors page." --- note (GUI:CreateLink, note style — not a banner). Its only link jumps to the shared, --- account-wide Color-by-Time editor on the Colors page and border-flashes that whole --- section so the eye lands on it. Used by the aura pages (buffs/debuffs/defensives) AND --- the Aura Designer wherever a "Color by Time Remaining" control (text, or the expiry --- Border/Tint modes) draws from those breakpoints — one cross-link, defined once. --- `width` flows the fixed-layout note up front (see GUI:CreateLink); the caller then --- AddWidget's it at note.layoutHeight. The |cffffffff is a parser placeholder — --- CreateLink re-tints the link itself. -function GUI:CreateColorsPageLink(parent, width) - local link = string.format("|cffffffff|HdfColors|h%s|h|r", L["Colors page"]) - local text = string.format(L["Customize duration colors on the %s."], link) - return GUI:CreateLink(parent, text, { - width = width, - onLinkClick = function() - GUI:LinkToSetting({ - page = "display_classcolors", - section = L["Color by Time"], - flash = { border = true, fill = false }, -- whole section → outline only - }) - end, - }) -end - --- Sibling of CreateColorsPageLink for the shared per-dispel-type palette: jumps to --- the Colors page and flashes its "Dispel Type Colors" section. Used by the debuff --- Border page and the Dispel Overlay page — both of which draw their dispel colours --- from that one account-wide set. -function GUI:CreateDispelColorsPageLink(parent, width) - local link = string.format("|cffffffff|HdfColors|h%s|h|r", L["Colors page"]) - local text = string.format(L["Set the per-dispel-type colours on the %s."], link) - return GUI:CreateLink(parent, text, { - width = width, - onLinkClick = function() - GUI:LinkToSetting({ - page = "display_classcolors", - section = L["Dispel Type Colors"], - flash = { border = true, fill = false }, - }) - end, - }) -end - --- Apply the standard button look to an existing Button frame — the single --- source of truth for button styling, shared by GUI:CreateButton AND by --- hand-rolled buttons that need the same look (the button analogue of --- GUI:StyleCheckButton). opts: --- width/height resize the button --- text create/set a centered DFFontHighlightSmall label (btn.Text) --- accent {r,g,b} — fixes the accent colour (e.g. ClickCasting green). --- Omit to use the mode accent (party purple / raid orange), --- tracking the theme. --- primary true → a prominent CTA: a persistent accent-tinted fill + --- accent border at rest (the hover wash just brightens it). Use --- for the main/confirming action; normal buttons are grey at rest. --- fadeActiveText true → on SetActive(true) dim btn.Text/btn.Icon to ~0.7 alpha --- (back to full when inactive). For an "almost always on" status --- toggle like Sync, where the active (synced) state is the resting --- norm so the label can recede. Leave OFF for momentary toggles --- (Test/Unlock) and selection toggles (chips/segmented), whose --- active text should stay bright/white. --- Hover respects the isTab/isActive convention used by the tab bar. Hover uses --- SetScript (matching the original CreateButton); buttons that also need a --- tooltip should HookScript their OnEnter so it composes with the hover. --- ============================================================ --- GUI TOOLTIP (settings-UI tooltips only — NOT unit-frame/aura tooltips) --- Single source for our own widget tooltips. Call from OnEnter — use HookScript --- on StyleButton'd widgets so it composes with the hover wash; SetScript on --- plain frames. Pair with OnLeave -> GUI:HideTooltip(). --- opts.title (string) white by default, or tone-coloured --- opts.tone nil | "warning" (gold) | "danger" (red) --- opts.anchor default: at the CURSOR, lifted clear of it (see CURSOR_LIFT). --- Krathe's call, 2026-07-27: a settings tooltip should appear where --- you are pointing, not pinned to a widget edge whose size you are --- not thinking about — but sitting ON the cursor buried the control --- you were reading about, so it is offset upward. --- ⚠ Do NOT pass one per call site. The whole point of the default --- living here is that every tooltip in the settings UI behaves the --- same; a page that sets its own is the disjointedness we just --- removed. ANCHOR_TOP in particular clamps over the owner near the --- top of the frame — it was in use 14 times and is now gone. --- opts.lines array; each element is one of: --- "text" -> body grey (0.7), wrapped --- " " -> blank spacer --- { text = , hint = true } -> dim grey (0.55) action hint, wrapped --- { text = , accent = true } -> mode/context accent colour, wrapped --- { text = , color = {r,g,b} } -> explicit colour, wrapped --- ============================================================ --- The line grammar, shared by ShowTooltip and ShowGameTooltip so a DF line --- appended under a spell tooltip reads exactly like one under a plain title. -local function AddTooltipLines(lines) - if not lines then return end - local acc - for _, line in ipairs(lines) do - if line == " " or line == "" then - GameTooltip:AddLine(" ") - elseif type(line) == "string" then - GameTooltip:AddLine(line, 0.7, 0.7, 0.7, true) - elseif type(line) == "table" and (line.text or line.left) then - local r, g, b = 0.7, 0.7, 0.7 - if line.hint then - r, g, b = 0.55, 0.55, 0.55 - elseif line.accent then - acc = acc or GetThemeColor() - r, g, b = acc.r, acc.g, acc.b - elseif line.color then - -- Accept {r=,g=,b=} or {r,g,b}: the palette uses the first, most - -- call sites building a colour inline reach for the second. - local c = line.color - r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] - end - if line.left then - -- Two-column form (label … value), for key/value dumps. Never - -- wraps -- AddDoubleLine has no wrap argument. - GameTooltip:AddDoubleLine(line.left, line.right, r, g, b, 1, 1, 1) - else - GameTooltip:AddLine(line.text, r, g, b, true) - end - end - end -end - --- How far the cursor-anchored tooltip is nudged off the pointer. ONE dial. --- --- Small on purpose. This started at 24 while the tooltip could still appear over --- a slider or dropdown, where it had to clear the whole control. Now that the --- hover lives on the LABEL only (see GUI:AttachTooltip) there is nothing --- underneath worth clearing — the lift just has to keep the tooltip off the --- words you are reading, so a nudge does it. --- --- ⚠ ANCHOR_CURSOR_RIGHT, not ANCHOR_CURSOR — plain ANCHOR_CURSOR DISCARDS the --- offsets. Verified against the client's own code rather than assumed: --- Blizzard_AuraContainer/Blizzard_AuraButton.lua asserts the signature --- SetOwner(point, offsetX, offsetY) with both offsets optional numbers, and --- Blizzard's own callers only ever pass offsets alongside the _RIGHT / _LEFT --- variants (QuestDataProvider "ANCHOR_CURSOR_RIGHT", 5, 2 — never with the plain --- cursor anchor). Positive Y is up in WoW, so this lifts regardless of which --- edge the anchor pins. -local CURSOR_LIFT_X, CURSOR_LIFT_Y = 0, 8 - -function GUI:ShowTooltip(owner, opts) - if not owner or not opts or not opts.title then return end - if opts.anchor then - GameTooltip:SetOwner(owner, opts.anchor) - else - GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT", CURSOR_LIFT_X, CURSOR_LIFT_Y) - end - -- Title colour is single-sourced from the tone's inline accent so a tooltip - -- title reads the same as inline ToneHex text of the same tone. Untoned = white. - local toneDef = opts.tone and INFO_BANNER_TONES[opts.tone] - local ac = toneDef and toneDef.accent - if ac then - GameTooltip:SetText(opts.title, ac[1], ac[2], ac[3]) - else - GameTooltip:SetText(opts.title, 1, 1, 1) - end - AddTooltipLines(opts.lines) - GameTooltip:Show() -end - --- ============================================================ --- GAME-DATA TOOLTIP (a spell / item / equipped item / aura, plus our own lines) --- The shape ShowTooltip cannot express: the game writes the header, we append --- underneath. Six settings-UI surfaces hand-rolled it — the whole binding editor --- plus the spell picker — and only the picker handled the case that actually --- bites: GameTooltip:SetSpellByID renders NOTHING when the client has not loaded --- that spell's data yet, so a bare call leaves an empty tooltip. Everywhere else --- silently showed nothing on a cold cache. --- --- opts (pick ONE source): --- spellID a spell — gets the load-on-demand retry below --- itemID an item by id --- inventorySlot [+ unit] an equipped item ("player" unless unit is given) --- unit + auraInstanceID a live aura --- plus: --- fallbackTitle shown when the game has no data at all, so a hover is never --- blank (for a spell, the id is added under it) --- isCurrent(owner, spellID) is this owner STILL showing this spell? Guards the --- async re-render on pooled / rebindable rows. Omit for a row --- that only ever shows one thing. --- anchor, lines exactly as ShowTooltip --- ============================================================ - --- Fill from the game. Returns whether it actually produced content. -local function SeedGameTooltip(opts) - local ok - if opts.spellID then - -- pcall: SetSpellByID errors outright on ids the client considers - -- invalid (possible for stale DB entries) — treat that as "no data". - ok = pcall(GameTooltip.SetSpellByID, GameTooltip, opts.spellID) - elseif opts.itemID then - ok = pcall(GameTooltip.SetItemByID, GameTooltip, opts.itemID) - elseif opts.inventorySlot then - ok = pcall(GameTooltip.SetInventoryItem, GameTooltip, opts.unit or "player", opts.inventorySlot) - elseif opts.unit and opts.auraInstanceID then - ok = pcall(GameTooltip.SetUnitAura, GameTooltip, opts.unit, opts.auraInstanceID) - else - return false - end - return ok and GameTooltip:NumLines() > 0 -end - -function GUI:ShowGameTooltip(owner, opts) - if not owner or not opts then return end - - -- Render the whole thing: game data (or the fallback), then our lines. Used - -- for the first paint AND the re-paint after a late spell load, so the - -- appended lines survive the reload instead of vanishing with it. - local function Fill() - local seeded = SeedGameTooltip(opts) - if not seeded then - if opts.fallbackTitle and opts.fallbackTitle ~= "" then - GameTooltip:AddLine(opts.fallbackTitle, 1, 1, 1) - end - if opts.spellID then - GameTooltip:AddLine(format(L["Spell IDs: %s"], tostring(opts.spellID)), 0.5, 0.5, 0.5) - end - end - AddTooltipLines(opts.lines) - GameTooltip:Show() - return seeded - end - - -- Same cursor default as ShowTooltip — a spell tooltip on a settings row has - -- to behave like every other tooltip in the window, and this one was still on - -- the old ANCHOR_RIGHT. - if opts.anchor then - GameTooltip:SetOwner(owner, opts.anchor) - else - GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT", CURSOR_LIFT_X, CURSOR_LIFT_Y) - end - if Fill() or not opts.spellID then return end - - -- Nothing rendered. If the data exists server-side but is not loaded yet, - -- this both requests the load and re-renders when it arrives. A cached spell - -- never reaches here (SetSpellByID already had its chance), so the callback - -- cannot double-add the fallback. - local spell = Spell and Spell.CreateFromSpellID and Spell:CreateFromSpellID(opts.spellID) - if not spell or spell:IsSpellEmpty() or spell:IsSpellDataCached() then return end - local spellID, isCurrent = opts.spellID, opts.isCurrent - spell:ContinueOnSpellLoad(function() - if GameTooltip:IsShown() and GameTooltip:IsOwned(owner) and owner:IsMouseOver() - and (not isCurrent or isCurrent(owner, spellID)) then - GameTooltip:ClearLines() - Fill() - end - end) -end - --- Counterpart to ShowTooltip: hide the shared GameTooltip. Wrapped so callers --- route through GUI instead of poking GameTooltip directly. -function GUI:HideTooltip() - GameTooltip:Hide() -end - --- ============================================================ --- ATTACHING a tooltip to a settings widget — ONE way, for every factory. --- --- Three factories used to each have their own idea, and on one page the same --- gesture did three different things: --- checkbox .tooltip hover the container ANCHOR_CURSOR --- dropdown .tooltip hover the BUTTON ANCHOR_CURSOR --- slider .tooltipText hover the SLIDER ANCHOR_RIGHT --- Worse, on a dropdown and a slider the LABEL sits above the control, outside --- its hit rect, so hovering the words never did anything — while on a checkbox --- (label beside the box, inside the container) it did. Five more factories — --- colour picker, font / texture dropdown, input, growth control — had no --- tooltip support at all, so ~136 controls could not carry one. --- --- The rule now: THE HIT AREA IS THE LABEL, and only the label. --- --- ⚠ This is deliberately NOT the whole widget. The first version of this hovered --- the control too, which is the obvious reading of "make the label work" — but a --- cursor-anchored tooltip then sits on top of the slider or dropdown you are --- trying to read and operate, and no amount of offsetting it fully solves that, --- because the thing you point at IS the thing being covered. Krathe's call, --- 2026-07-27, after trying both. Reading and adjusting are separate gestures: --- point at the words to find out what it does, point at the control to use it. --- --- The label is a FontString and cannot take mouse input, so each widget gets one --- invisible frame sized to the label's own rect. That also handles a label wider --- than its container for free (the checkbox case) — the frame follows the TEXT, --- not the box, so there is no hit-rect arithmetic to keep in sync. --- --- The anchor is whatever ShowTooltip defaults to — set in ONE place so no page --- can drift. Nothing here passes an anchor, deliberately. --- --- The spec is read AT HOVER TIME, not when it is attached — every call site --- sets it after creation, on the container the factory returned: --- widget.tooltip = "body" title = the widget's own label --- widget.tooltip = { title=, lines=, tone= } the full ShowTooltip shape --- widget.tooltipText / .tooltipSubText legacy pair, still honoured --- ============================================================ -local function ResolveTooltipSpec(widget, label) - local t = widget.tooltip - if type(t) == "table" then - -- Full spec from the caller. Default the title to the label so the - -- common case only has to say what the setting DOES. - if t.title == nil then t.title = label end - return t - end - if type(t) == "string" and t ~= "" then - return { title = label, lines = { t } } - end - -- Legacy pair. Deliberately NOT re-titled from the label: these read as - -- title-then-subtitle by design (the Frame Level explainer, the override - -- markers), and re-titling them would change tooltips that are already - -- correct. New code should use .tooltip. - if widget.tooltipText then - return { - title = widget.tooltipText, - lines = widget.tooltipSubText and { widget.tooltipSubText } or nil, - } - end - return nil -end - --- widget the frame the caller holds and sets .tooltip on (the container) --- label the default title --- labelRegion the label FontString — the hit frame is built over ITS rect -function GUI:AttachTooltip(widget, label, labelRegion) - if not labelRegion then return end - - local hit = CreateFrame("Frame", nil, widget) - -- Two-corner anchored to the FontString, so it tracks the text if the label - -- is ever re-set or re-fonted. The 2px vertical bleed makes a single line of - -- small text comfortable to hit without reaching the control below it. - hit:SetPoint("TOPLEFT", labelRegion, "TOPLEFT", 0, 2) - hit:SetPoint("BOTTOMRIGHT", labelRegion, "BOTTOMRIGHT", 0, -2) - hit:EnableMouse(true) - -- Above the widget's own children so the label area wins the mouse, but it - -- only ever covers the TEXT, so nothing clickable is behind it. - hit:SetFrameLevel((widget:GetFrameLevel() or 0) + 5) - - hit:SetScript("OnEnter", function() - local spec = ResolveTooltipSpec(widget, label) - if spec then GUI:ShowTooltip(hit, spec) end - end) - hit:SetScript("OnLeave", function() GUI:HideTooltip() end) - - widget.dfTooltipHit = hit -- exposed for a caller that needs to re-anchor it - return hit -end - -function GUI:StyleButton(btn, opts) - opts = opts or {} - if opts.width or opts.height then - btn:SetSize(opts.width or btn:GetWidth(), opts.height or btn:GetHeight()) - end - -- Land the height on an EVEN number of device pixels, whether it came from - -- opts or the caller sized the button itself. Buttons are chained with - -- centre-aligning anchors (Copy <- Sync <- Reset, Clicks <- Test <- Unlock), - -- so an odd height puts the whole row on a half pixel -- and since nothing - -- corrects a control's position at runtime, getting the size right at - -- construction is the only thing that keeps their edges crisp. - -- Construction-time and once, so it cannot drive an OnSizeChanged cascade. - local bh = btn:GetHeight() - if bh and bh > 0 then - local sh = SnapHeightEven(btn, bh) - if sh and math.abs(sh - bh) > 1e-4 then btn:SetHeight(sh) end - end - CreateElementBackdrop(btn) -- mixes in BackdropTemplate if needed - - -- Optional label + leading icon. opts.icon = { texture, size (14), - -- color {r,g,b}, gap (4) }. opts.align controls layout: - -- "center" (default) — centre the icon+label as a GROUP (text-only centres - -- the label; icon-only centres the icon). Best for compact buttons whose - -- width ~ their content. - -- "left" — pin the icon at opts.leftPad (12) with the label after it. Best - -- for wide / full-width list-style buttons, where centred content floats - -- in a sea of empty space. - local iconOpt = opts.icon - local iconGap = (iconOpt and iconOpt.gap) or 4 - local iconW = (iconOpt and (iconOpt.size or 18)) or 0 - local hasText = opts.text ~= nil and opts.text ~= "" - local align = opts.align or "center" - local leftPad = opts.leftPad or 12 - -- Toned buttons (danger / success): neutral at rest with an accent-coloured - -- label+icon — soft red for destructive, soft green for affirmative — plus the - -- accent hover wash. A coloured-text button, NOT a filled CTA (the accent set - -- below drives the hover). Mirrors each other so Delete/Save read as a pair. - local toneLabel = (opts.tone == "danger" and { 0.9, 0.45, 0.45 }) - or (opts.tone == "success" and { 0.4, 0.85, 0.5 }) or nil - - if opts.text ~= nil then - if not btn.Text then - btn.Text = btn:CreateFontString(nil, "OVERLAY", opts.font or "DFFontHighlightSmall") - -- Register it as the button's font string so the NATIVE Button:SetText - -- / GetText keep working. Without this, a caller that relabels later - -- (a Start/Stop or Pause/Resume toggle) silently no-ops and the button - -- freezes on its first label -- an easy regression when converting a - -- Blizzard-template button, which always had one. - if btn.SetFontString then btn:SetFontString(btn.Text) end - end - btn.Text:SetText(opts.text) - if toneLabel then - btn.Text:SetTextColor(toneLabel[1], toneLabel[2], toneLabel[3]) - else - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - - if iconOpt then - btn.Icon = btn.Icon or btn:CreateTexture(nil, "OVERLAY") - btn.Icon:SetTexture(iconOpt.texture) - btn.Icon:SetSize(iconW, iconW) - if iconOpt.color then - btn.Icon:SetVertexColor(iconOpt.color.r, iconOpt.color.g, iconOpt.color.b) - elseif toneLabel then - btn.Icon:SetVertexColor(toneLabel[1], toneLabel[2], toneLabel[3]) - end - end - - -- Anchor the icon/label per alignment. - if align == "left" then - if iconOpt then - btn.Icon:ClearAllPoints() - btn.Icon:SetPoint("LEFT", leftPad, 0) - end - if btn.Text then - btn.Text:ClearAllPoints() - if iconOpt then - btn.Text:SetPoint("LEFT", btn.Icon, "RIGHT", iconGap, 0) - else - btn.Text:SetPoint("LEFT", leftPad, 0) - end - end - else - if btn.Text then - btn.Text:ClearAllPoints() - -- Offset right by half the icon+gap so the icon+label GROUP centres. - btn.Text:SetPoint("CENTER", btn, "CENTER", (iconOpt and hasText) and (iconW + iconGap) / 2 or 0, 0) - end - if iconOpt then - btn.Icon:ClearAllPoints() - if hasText then - btn.Icon:SetPoint("RIGHT", btn.Text, "LEFT", -iconGap, 0) - else - btn.Icon:SetPoint("CENTER", btn, "CENTER", 0, 0) - end - end - end - - -- Hover: an accent wash via the native HIGHLIGHT layer (auto-shown on - -- mouseover, like StyleCheckButton / the menu buttons) PLUS a darker accent - -- border for definition. `primary` buttons additionally keep a persistent - -- accent-tinted fill + accent border at rest. Accent = explicit opts.accent - -- (e.g. ClickCasting green) or the mode accent (party purple / raid orange). - local accent = opts.accent - -- tone presets: a destructive "danger" button reuses ALL the accent - -- machinery (hover wash, hover border, primary fill) with a fixed FF4444 red. - -- So a plain danger button is neutral-at-rest with a red hover, and - -- danger+primary is a filled red CTA. Fixed colour ⇒ it won't theme-track - -- (correct — destructive red shouldn't follow the party/raid accent). - if not accent then - if opts.tone == "danger" then - accent = { r = 1, g = 0.27, b = 0.27 } - elseif opts.tone == "success" then - accent = { r = 0.3, g = 0.8, b = 0.45 } - end - end - local primary = opts.primary - local fadeActiveText = opts.fadeActiveText - -- Underline TAB style (opts.tab): the button is transparent (no fill/border) - -- and its active cue is a 2px accent stripe along the bottom + an accent label - -- (dim label when inactive). Driven by SetActive, like a toggle. Distinct from - -- the legacy `isTab` filled-sidebar branch in restBackdrop. - local isTabStyle = opts.tab - -- Ghost action (opts.ghost): transparent like a tab but with no underline — an - -- accent label + faint hover wash. For quiet inline actions (e.g. "+ Add"). - local ghost = opts.ghost - -- Persistent semantic accent (opts.tinted): the accent is meaningful and stays - -- ON at rest — faint accent fill + accent border + accent label — rather than - -- being a neutral button with an accent hover. For role quick-add buttons etc. - -- where the colour IS the button's identity. Pass a fixed opts.accent. - local tinted = opts.tinted - -- Neutral hover (opts.hoverTone = "neutral"): the wash is the plain C_HOVER - -- grey and the border does NOT go accent. For surfaces that are a PLACE - -- rather than an action -- a card header, a list row -- where an accent - -- hover would read as "this is a call to action". Card headers and dropdown - -- rows previously hand-rolled this as an OnEnter/OnLeave SetBackdropColor - -- swap, which duplicated the rest colours at every site. - local neutralHover = opts.hoverTone == "neutral" - -- opts.restBorderColor: let a consumer keep its OWN border identity at rest -- - -- e.g. an Aura/Text group card header tinted by that group's colour -- while - -- fill, hover, selection and disabled all stay shared. Applies ONLY to the - -- neutral rest branch; active / primary / tinted keep their accent-derived - -- borders, so the override can't fight a state the button is in. - local restBorder = opts.restBorderColor - local hl = btn:GetHighlightTexture() or btn:CreateTexture(nil, "HIGHLIGHT") - hl:SetTexture("Interface\\Buttons\\WHITE8x8") - hl:SetAllPoints(btn) - btn.Highlight = hl - - if isTabStyle then - local stripe = btn:CreateTexture(nil, "OVERLAY") - stripe:SetTexture("Interface\\Buttons\\WHITE8x8") - stripe:SetHeight(3) - stripe:SetPoint("BOTTOMLEFT", 0, 0) -- full-width underline (no insets) - stripe:SetPoint("BOTTOMRIGHT", 0, 0) - stripe:Hide() - btn.dfTabStripe = stripe - end - - -- The resting backdrop the button returns to on mouse-out (and that primary - -- buttons also wear permanently): accent-tinted for primary, the active-tab - -- panel colour for active tabs, otherwise the neutral element colour. - local function restBackdrop(self, a) - if isTabStyle then - -- Underline tab: a faint neutral cell when inactive (so every tab's - -- bounds stay visible and the active one doesn't appear to "grow"), - -- and a stronger accent fill when active (a held-hover highlight) - -- beneath its stripe. - if self.dfActive then - self:SetBackdropColor(a.r, a.g, a.b, 0.18) - else - self:SetBackdropColor(1, 1, 1, 0.05) - end - self:SetBackdropBorderColor(0, 0, 0, 0) - return - end - if ghost then - -- Ghost action: a faint neutral cell (matching inactive tabs) with an - -- accent label, so it sits consistently in a tab strip; the wash - -- brightens it on hover. - self:SetBackdropColor(1, 1, 1, 0.05) - self:SetBackdropBorderColor(0, 0, 0, 0) - return - end - if tinted then - -- Persistent semantic accent: faint accent fill + medium accent border - -- at rest (label/icon accent-coloured in ApplyThemeColor). Hover adds a - -- full-accent border + the wash brightens the fill. - self:SetBackdropColor(a.r * 0.15, a.g * 0.15, a.b * 0.15, 0.9) - self:SetBackdropBorderColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 0.8) - return - end - if self.dfActive then - -- Selected toggle/segmented button: a subtle accent fill + a clear - -- accent border (more than the muted hover border, but toned down from - -- full so it doesn't read as a heavy bright outline). - self:SetBackdropColor(a.r * 0.3, a.g * 0.3, a.b * 0.3, 1) - self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) - elseif primary then - -- Filled accent CTA: a medium accent fill with a slightly darker - -- accent border (the same border-darker-than-fill relationship as the - -- standard hover) so it reads like an emphasised standard button, not - -- a dark fill ringed by a harsh bright outline. - self:SetBackdropColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 1) - self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) - elseif self.isTab and self.isActive then - self:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 1) - self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - else - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - if restBorder then - self:SetBackdropBorderColor(restBorder.r or restBorder[1], - restBorder.g or restBorder[2], - restBorder.b or restBorder[3], - restBorder.a or restBorder[4] or 1) - else - self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - end - end - end - - -- The hover wash's colour, factored out so it can be re-resolved at HOVER time - -- rather than only at build time. The theme listener registered below lands on - -- the button's PARENT, and a button parented into a scroll child -- every row - -- in the Filter Designer, the spell list, the binding editor -- hangs it on a - -- frame no theme walk ever visits. Its wash then stays frozen at whatever the - -- accent was when the page was built, so a raid-mode hover drew an orange - -- border (computed live in OnEnter) over a party-blue fill. The border was - -- always right; the wash simply wasn't asking again. - local function applyWash(c) - if neutralHover then - hl:SetVertexColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.55) - else - hl:SetVertexColor(c.r, c.g, c.b, (isTabStyle or ghost) and 0.15 or 0.30) - end - end - - btn.ApplyThemeColor = function(c) - applyWash(c) - if isTabStyle then - restBackdrop(btn, c) -- keep the tab transparent (no fill/border) - -- refresh the stripe colour + the active label to the new accent - if btn.dfTabStripe then btn.dfTabStripe:SetColorTexture(c.r, c.g, c.b, 1) end - if btn.dfActive and btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end - elseif ghost or tinted then - restBackdrop(btn, c) -- faint cell / tinted fill; accent-coloured label - if btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end - if btn.Icon then btn.Icon:SetVertexColor(c.r, c.g, c.b) end -- icon matches the accent label - elseif primary or btn.dfActive then - restBackdrop(btn, c) -- refresh persistent accent - end - end - - -- Toggle/segmented selection: btn:SetActive(true) marks the button as the - -- current selection (prominent accent border via restBackdrop); false returns - -- it to its normal rest. The owning group is responsible for clearing the - -- previously-active button. Works on any StyleButton'd button. - btn.SetActive = function(self, active) - self.dfActive = active and true or false - restBackdrop(self, accent or GetThemeColor()) - if isTabStyle then - -- Underline tab: show the accent stripe + accent label when active, - -- dim label when inactive. - local a = accent or GetThemeColor() - if self.dfTabStripe then - self.dfTabStripe:SetColorTexture(a.r, a.g, a.b, 1) - self.dfTabStripe:SetShown(self.dfActive) - end - if self.Text then - if self.dfActive then - self.Text:SetTextColor(a.r, a.g, a.b) - else - self.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - end - if fadeActiveText then - -- Status toggles: the active fill+border carry the "on" emphasis, so - -- the label/icon recede slightly when active (settled) and stay bright - -- when inactive (a clearer call to action). Alpha keeps this - -- independent of whatever colour the owner sets on the text/icon. - local a = self.dfActive and 0.7 or 1 - if self.Text then self.Text:SetAlpha(a) end - if self.Icon then self.Icon:SetAlpha(a) end - end - end - - -- Disabled / "greyed out": a dim backdrop + faint border, label/icon dimmed - -- via alpha (keeps their own colour, just recedes), and the hover wash + - -- border suppressed. The button stays natively enabled so a HookScript - -- tooltip can still explain WHY it's disabled; the owner's OnClick must - -- early-out on self.dfDisabled. SetDisabled(false) restores the normal/ - -- active/primary rest. - btn.SetDisabled = function(self, disabled) - disabled = disabled and true or false - -- Idempotent: bail when the state isn't actually changing. Tab refresh - -- paths (the Aura Designer's UpdateLayoutTabState) call SetDisabled(false) - -- on the sub-tabs on EVERY rebuild. Re-running the enable-restore below on - -- an already-enabled tab left the AD Effects/Layout tabs diverging from a - -- never-disabled tab (Global) and broke their hover wash — the only code - -- that ran on them but not on Global was this call. Skipping the no-op keeps - -- an enabled tab identical to one SetDisabled never touched. - if (self.dfDisabled and true or false) == disabled then return end - self.dfDisabled = disabled - if self.dfDisabled then - self:SetBackdropColor(C_ELEMENT.r * 0.55, C_ELEMENT.g * 0.55, C_ELEMENT.b * 0.55, 0.6) - self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.25) - -- Kill the hover wash through its VERTEX alpha — the same channel - -- ApplyThemeColor uses for the wash's rest STRENGTH. The old object- - -- alpha toggle (hl:SetAlpha 0/1) mixed two alpha channels, so a - -- disable->enable cycle could restore the wash at FULL strength - -- instead of 0.30 (seen live on the Filter Designer add/rename/ - -- delete buttons when switching a preset -> a custom filter). - local wc = accent or GetThemeColor() - hl:SetVertexColor(wc.r, wc.g, wc.b, 0) - if self.Text then self.Text:SetAlpha(0.35) end - if self.Icon then self.Icon:SetAlpha(0.35) end - else - self.ApplyThemeColor(accent or GetThemeColor()) -- re-assert the wash's rest colour + alpha (0.30 / 0.15) - restBackdrop(self, accent or GetThemeColor()) - if self.Text then self.Text:SetAlpha(1) end - if self.Icon then self.Icon:SetAlpha(1) end - end - end - -- The grey loop (RefreshChildStates) greys gated children via widget:SetEnabled. - -- Native Button:SetEnabled blocks clicks but won't dim a custom-backdrop button - -- (its backdrop/Text are custom, not native button regions), so layer a SetAlpha - -- dim on top. We route through native + SetAlpha, NOT SetDisabled — SetDisabled - -- stays natively clickable (it relies on an OnClick dfDisabled early-out the - -- consumer may not have) and fights the hover wash on SetActive toggles. - local nativeSetEnabled = btn.SetEnabled - btn.SetEnabled = function(self, enabled) - nativeSetEnabled(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - end - - btn.ApplyThemeColor(accent or GetThemeColor()) - if not accent then - btn.UpdateTheme = function() btn.ApplyThemeColor(GetThemeColor()) end - local root = opts.themeRoot or btn:GetParent() - if root then - root.ThemeListeners = root.ThemeListeners or {} - table.insert(root.ThemeListeners, btn) - end - end - - -- WHAT HOVERED LOOKS LIKE — the single implementation, so the mouse and a - -- proxying owner cannot drift apart. OnEnter/OnLeave below are thin wrappers. - -- - -- ⚠ NO CURRENT CONSUMER. The Filter Designer's membership button was the one - -- caller and went with the filters merge (FilterRegistry/Options.lua says so). - -- Kept because the pattern recurs and the Lock/UnlockHighlight subtlety below - -- is not obvious enough to want rediscovered. - -- - -- Call btn:SetHovered(true/false) when something ELSE owns the hit area and - -- forwards the click: a list row whose OnClick fires this button's action. - -- The row lighting its button says "this is what clicking the row does", and - -- it separates the button from the row's highlight by HUE, which is far more - -- robust than the couple of hundredths of grey that sit between C_HOVER and - -- C_ELEMENT (the Filter Designer's spell rows are exactly that case). - -- - -- ⚠ Only wire this where the owner's click REALLY performs this button's - -- action. A control the owner does not fire must not light up with it — - -- priming a destructive button that the row will not actually trigger is - -- worse than the legibility problem it would be solving. - -- - -- The wash lives on the HIGHLIGHT layer, which the client shows only for the - -- frame under the mouse, so a proxied hover needs Lock/UnlockHighlight — it - -- cannot just Show() the texture. The real mouseover is unaffected either - -- way: locking an already-hovered button is a no-op, and unlocking one still - -- under the mouse leaves the client's own highlight up. - local function applyHoverState(self, hovered) - if not hovered then - if isTabStyle or ghost then return end - if self:IsEnabled() and not self.dfDisabled then - restBackdrop(self, accent or GetThemeColor()) - end - return - end - -- Re-resolve the wash against the CURRENT theme, exactly as the border does - -- below (see applyWash). Skipped when the caller pinned a fixed accent, and - -- while disabled -- SetDisabled parks the wash at alpha 0 and it must stay - -- parked, or a disabled button would light up under the mouse. - if not accent and not self.dfDisabled then applyWash(GetThemeColor()) end - -- tab/ghost/neutral: only the auto wash, no accent border - if isTabStyle or ghost or neutralHover then return end - if self:IsEnabled() and not self.dfDisabled then - local a = accent or GetThemeColor() - if tinted then - self:SetBackdropBorderColor(a.r, a.g, a.b, 1) -- full accent border on hover - elseif self.dfActive then - -- keep the active border on hover (the wash still brightens the - -- fill, giving the hover cue). - self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) - else - -- border darkens to a shade of the accent; the HIGHLIGHT wash - -- brightens the fill. The wash is translucent (0.3), so the - -- full-opacity border still reads DARKER than the fill. Same for - -- primary — it keeps its edge and the brightening fill is the cue. - self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) - end - end - end - - -- The proxied entry point. Lock/Unlock is HERE and not in applyHoverState so - -- a real mouseover never locks: a pooled button hidden mid-hover (a list - -- refreshing under a stationary mouse) would miss its OnLeave and come back - -- lit. An owner calling SetHovered accepts that responsibility instead and - -- must clear it when it rebinds the row. - function btn:SetHovered(hovered) - -- Lock/UnlockHighlight are Button-only, and StyleButton is applied to a - -- few plain Frames too; those still get the border half of the state. - if self.LockHighlight then - if hovered then self:LockHighlight() else self:UnlockHighlight() end - end - applyHoverState(self, hovered) - end - - btn:SetScript("OnEnter", function(self) applyHoverState(self, true) end) - btn:SetScript("OnLeave", function(self) applyHoverState(self, false) end) - return btn -end - -function GUI:CreateButton(parent, text, width, height, func, iconName) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - local opts = { width = width or 120, height = height or 22, text = text } - -- Optional leading icon by Media\Icons name (14px to suit the small buttons). - if iconName then - opts.icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, size = 14 } - end - GUI:StyleButton(btn, opts) - btn:SetScript("OnClick", function(self) - if func then func(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - return btn -end - --- Standard close/dismiss button: a small square danger-toned button showing a --- "×" glyph. Replaces the many hand-rolled red close buttons on dialogs/panels. --- opts = { size (20), onClick, tooltip, tone }. --- tone = nil → dim grey "×" at rest → white on hover (close/dismiss; default) --- tone = "danger" → RED "×" at rest → brighter red on hover (inline destructive --- removes: list-item / tag removes). Both keep the red hover wash. --- A horizontal row of buttons, chained left-to-right with one gap, sized as a --- single layout slot. Pages were building this by hand every time -- a bare --- CreateFrame, then SetPoint("LEFT", prev, "RIGHT", 6, 0) per button, plus the --- HookScript/ShowTooltip pair on any button that needed a tooltip. --- --- buttons = { { label, width, onClick, icon, tooltip, key }, ... } --- tooltip is a ShowTooltip spec ({title, lines, tone}); a button IS its own --- label, so it takes the whole-widget hover rather than AttachTooltip's --- label-only hit area (AttachTooltip returns early without a label region). --- key names the button on row.buttons for a caller that needs it later. -function GUI:CreateButtonRow(parent, buttons, opts) - opts = opts or {} - local gap, h = opts.gap or 6, opts.height or 24 - local row = CreateFrame("Frame", nil, parent) - row:SetSize(opts.width or 540, opts.rowHeight or (h + 4)) - row.buttons = {} - local prev - for i, spec in ipairs(buttons) do - local btn = GUI:CreateButton(row, spec.label, spec.width or 80, h, spec.onClick, spec.icon) - if prev then - btn:SetPoint("LEFT", prev, "RIGHT", gap, 0) - else - btn:SetPoint("LEFT", 0, 0) - end - if spec.tooltip then - btn:HookScript("OnEnter", function(self) GUI:ShowTooltip(self, spec.tooltip) end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - row.buttons[spec.key or i] = btn - prev = btn - end - return row -end - -function GUI:CreateCloseButton(parent, opts) - opts = opts or {} - local size = opts.size or 20 - -- Rest/hover glyph colours: grey→white for dismiss, red→brighter-red for inline - -- destructive removes. The StyleButton red wash + border is shared by both. - local restColor = (opts.tone == "danger") and { r = 0.9, g = 0.45, b = 0.45 } or C_TEXT_DIM - local hoverColor = (opts.tone == "danger") and { r = 1, g = 0.4, b = 0.4 } or { r = 1, g = 1, b = 1 } - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = size, height = size, - tone = "danger", - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", - size = math.max(8, math.floor(size * 0.55)), - color = restColor, - }, - }) - btn:HookScript("OnEnter", function(self) self.Icon:SetVertexColor(hoverColor.r, hoverColor.g, hoverColor.b) end) - btn:HookScript("OnLeave", function(self) self.Icon:SetVertexColor(restColor.r, restColor.g, restColor.b) end) - btn:SetScript("OnClick", function(self) - if opts.onClick then opts.onClick(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - if opts.tooltip then - btn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = opts.tooltip}) - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - return btn -end - --- A bare clickable GLYPH: an icon with a hover cue and NO chrome. This is the --- small affordance that lives inside a row or a card header -- reorder arrows, an --- eye visibility toggle, a clear-search "x" -- where a button box would be --- heavier than the thing it acts on. --- --- Deliberately its own helper: StyleButton always draws chrome, and --- CreateCloseButton is specifically the chromed "x". Before this existed, ~16 --- sites hand-rolled the same three lines (create texture, tint it dim, brighten --- it in OnEnter and restore in OnLeave). --- --- NOT this: a labelled row that merely CONTAINS an icon (a collapsible section --- header with a title + chevron, a collapse bar). There the click target is the --- whole row, not the glyph. --- --- opts: --- texture icon path tooltip / onClick --- size both dims (16) width / height -- button box, when the hit --- area is deliberately bigger than the art --- iconSize art size (= size) rotation -- radians, so one arrow texture --- can serve both directions --- color rest tint (C_TEXT_DIM) hoverColor (white) --- --- Returns the button with .Icon plus: --- :SetGlyph(texture, color) re-point the art for a state change. The colour --- passed becomes the new REST colour, so a later OnLeave restores the --- state rather than snapping back to the original default. --- :SetGlyphHover(bool) suppress the hover brighten -- an "off" state should --- not light up under the mouse. -function GUI:CreateGlyphButton(parent, opts) - opts = opts or {} - local size = opts.size or 16 - local iconSize = opts.iconSize or size - - local btn = CreateFrame("Button", nil, parent) - btn:SetSize(opts.width or size, opts.height or size) - - local icon = btn:CreateTexture(nil, "OVERLAY") - icon:SetSize(iconSize, iconSize) - icon:SetPoint("CENTER", 0, 0) - if opts.texture then icon:SetTexture(opts.texture) end - if opts.rotation then icon:SetRotation(opts.rotation) end - btn.Icon = icon - - local function unpackColor(c, dr, dg, db) - if not c then return dr, dg, db end - return c.r or c[1], c.g or c[2], c.b or c[3] - end - local hr, hg, hb = unpackColor(opts.hoverColor, 1, 1, 1) - btn._glyphHover = true - btn._glyphRest = { unpackColor(opts.color, C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) } - icon:SetVertexColor(unpack(btn._glyphRest)) - - function btn:SetGlyph(texture, color) - if texture then self.Icon:SetTexture(texture) end - if color then self._glyphRest = { unpackColor(color) } end - self.Icon:SetVertexColor(unpack(self._glyphRest)) - end - - function btn:SetGlyphHover(enabled) - self._glyphHover = enabled and true or false - end - - btn:SetScript("OnEnter", function(self) - if self._glyphHover then self.Icon:SetVertexColor(hr, hg, hb) end - if opts.tooltip then GUI:ShowTooltip(self, { title = opts.tooltip }) end - end) - btn:SetScript("OnLeave", function(self) - self.Icon:SetVertexColor(unpack(self._glyphRest)) - if opts.tooltip then GUI:HideTooltip() end - end) - if opts.onClick then - btn:SetScript("OnClick", function(self) opts.onClick(self) end) - end - return btn -end - --- Shared panel/dialog root backdrop: a solid dark panel with an optional 1px --- border. Centralises the inline SetBackdrop blocks scattered across dialogs and --- floating panels. opts = { bgAlpha (0.95), border (true), borderColor {r,g,b,a} --- or {r,g,b,a array} }. -function GUI:CreatePanelBackdrop(frame, opts) - opts = opts or {} - local bg = opts.bgColor or C_PANEL - -- A panel's border is a full-strength 1px line, where the element default is - -- half-alpha, so the border colour is always passed explicitly rather than - -- left to CreateElementBackdrop's default. - local bc = opts.borderColor - return CreateElementBackdrop(frame, { - outline = opts.border ~= false, - bgColor = { bg.r or bg[1], bg.g or bg[2], bg.b or bg[3], - opts.bgAlpha or bg.a or 0.95 }, - borderColor = bc and { bc.r or bc[1], bc.g or bc[2], bc.b or bc[3], - bc.a or bc[4] or 1 } - or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 1 }, - }) -end - --- Mover chrome: the translucent tinted plate a drag surface wears while the frames --- are unlocked. This is a SEPARATE helper from CreateElementBackdrop, not a flag on --- it, because a mover has the opposite job from settings chrome -- it is meant to --- shout. Giving movers the neutral element look would be a bug, not consistency. --- --- The hue comes from the GUI theme constants (C_ACCENT party purple-blue / C_RAID --- raid orange) instead of a hardcoded literal, so retheming moves the movers too. --- --- ⚠ Which POLE is the caller's choice, not GUI.SelectedMode's, because a mover --- belongs to the thing it moves: the raid mover must stay orange even while the --- options window happens to be showing a party page. Pass isRaid where the site --- knows; omit it only where the mover genuinely has no mode, and it will follow --- the selected mode. --- --- opts: --- isRaid true/false pins the pole; omit to follow GUI.SelectedMode --- color {r,g,b} or {[1],[2],[3]} -- explicit override, for a mover whose --- colour is a user setting rather than the theme --- fillAlpha default 0.30 borderAlpha default 0.80 --- fill = false outline only edgeSize default 2 --- --- The returned frame gains :RefreshMoverTint(), which re-resolves the hue against --- the theme as it stands now -- call it if the mode changes while a mover is shown. -function GUI:CreateMoverBackdrop(frame, opts) - opts = opts or {} - local c = opts.color - if not c then - if opts.isRaid ~= nil then c = GetThemeColorFor(opts.isRaid) - else c = GetThemeColor() end - end - local r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] - CreateElementBackdrop(frame, { - fill = opts.fill, - edgeSize = opts.edgeSize or 2, - bgColor = { r, g, b, opts.fillAlpha or 0.30 }, - borderColor = { r, g, b, opts.borderAlpha or 0.80 }, - }) - frame.RefreshMoverTint = function(self, newOpts) - return GUI:CreateMoverBackdrop(self, newOpts or opts) - end - return frame -end - --- The element backdrop, exposed to consumer files (the stylers in this file use --- the local directly). Nothing outside should be calling SetBackdrop itself -- --- route it through here so the look, and the border mechanism, stay in one --- place. See the local for the opts contract: fill, outline, bgColor, --- borderColor, backdropEdge. -function GUI:CreateElementBackdrop(frame, opts) - return CreateElementBackdrop(frame, opts) -end - --- ============================================================ --- DESIGNER TEMPLATE BAR (shared by the Aura / Text Designer editors) --- Compact row: "Template: [dropdown ▾] [New][Duplicate][Rename][Delete]". --- NOTE: the saved keys are still auraDesignerPreset(s) / textDesignerPreset(s) -- --- only the LABELS became "template". The keys are persisted and exported, so --- renaming them would cost a profile migration for nothing a user can see. --- "Preset" now means only the built-in filter sets (FilterRegistry) and the --- export/test quick-picks. --- Picking a template assigns it to the mode (opts.getMode()) AND retargets the --- editor; the buttons manage the library. After any change the bar calls --- opts.onChange() so the host page can rebuild + refresh live frames. --- opts = { kind = "aura"|"text", getMode = fn->mode, onChange = fn }. --- Returns the bar frame; call bar:Refresh() to resync. --- ============================================================ - --- The addon's ONE name prompt. This was hand-rolled twice against Blizzard's --- StaticPopup edit box — here and in the Filter Designer — each copy carrying --- the same `self.EditBox or self.editBox or self:GetEditBox()` fallback for a --- field name that moves between client versions. Both now come through here and --- get DF chrome, so there is nothing left to keep in step. --- opts = { title, message, default, acceptLabel, maxLetters, onAccept(text) } -function GUI:PromptName(opts) - opts = opts or {} - DF:ShowPopupInput({ - title = opts.title, - message = opts.message, - text = opts.default or "", - acceptLabel = opts.acceptLabel, - maxLetters = opts.maxLetters or 40, - onAccept = function(text) - -- Trim here so every caller's uniqueness check and empty-name - -- fallback sees the same thing. - if opts.onAccept then opts.onAccept(strtrim(text or "")) end - end, - }) -end - -local function PromptPresetName(message, default, acceptLabel, callback) - GUI:PromptName({ - title = L["Template Name"], - message = message, - default = default, - acceptLabel = acceptLabel, - onAccept = callback, - }) -end - -local function ConfirmDeletePreset(kind, name, onDone) - DF:ShowPopupAlert({ - title = L["Delete Template"], - message = format(L["Delete template \"%s\"? Anything using it reverts to Default."], name), - buttons = { - { - label = L["Delete"], - onClick = function() - if DF.DeleteDesignerPreset then - DF:DeleteDesignerPreset(kind, name) - if onDone then onDone() end - end - end, - }, - { label = L["Cancel"] }, - }, - }) -end - -function GUI:CreateDesignerPresetBar(parent, opts) - opts = opts or {} - local kind = opts.kind or "aura" - local getMode = opts.getMode or function() return "party" end - local onChange = opts.onChange or function() end - - local bar = CreateFrame("Frame", nil, parent) - bar:SetHeight(24) - - local label = bar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("LEFT", 0, 0) - label:SetText(L["Template:"]) - label:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local function CurrentName() - return DF:GetModeDesignerPresetName(kind, getMode()) - end - - -- True while editing a raid auto-layout (the only context with an "inherit - -- the global preset" choice — normal party/raid modes ARE the base). - -- Mode-gated: auto-layouts are RAID-only, but the GUI can be reopened on - -- the party tab while editing (ToggleGUI re-derives SelectedMode) — the - -- PARTY preset bar must not show layout state, and its "Inherit (Global)" - -- click must never clear the RAID layout's override. - local function IsEditingLayout() - return getMode() == "raid" - and DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() - end - - -- The label to show on the dropdown button: "Inherit (Global)" when the - -- edited layout has no override, otherwise the resolved preset name. - local function CurrentLabel() - if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then - return L["Inherit (Global)"] - end - return CurrentName() - end - - -- Dropdown button + menu (rebuilt on each open so it always reflects the lib) - local ddBtn = CreateFrame("Button", nil, bar, "BackdropTemplate") - ddBtn:SetSize(150, 22) - ddBtn:SetPoint("LEFT", label, "RIGHT", 6, 0) - CreateElementBackdrop(ddBtn) - ddBtn.text = ddBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - ddBtn.text:SetPoint("LEFT", 6, 0) - ddBtn.text:SetPoint("RIGHT", -16, 0) - ddBtn.text:SetJustifyH("LEFT") - ddBtn.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - local arrow = ddBtn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -4, 0) - arrow:SetSize(10, 10) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local menu = CreateFrame("Frame", nil, ddBtn, "BackdropTemplate") - menu:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menu) - menu:SetPoint("TOPLEFT", ddBtn, "BOTTOMLEFT", 0, -1) - menu:SetWidth(150) - CreatePanelBackdrop(menu) - menu:Hide() - - -- Row pool: frames can't be garbage-collected in WoW, so recreating the - -- items on every open (the old Hide+SetParent(nil) approach) leaked a row - -- set per click. Reuse instead. - local menuRows = {} - local function BuildMenu() - for _, row in ipairs(menuRows) do row:Hide() end - local used = 0 - local y = -4 - local function AddItem(label, onClick) - used = used + 1 - local item = menuRows[used] - if not item then - item = CreateFrame("Button", nil, menu) - item:SetHeight(20) - item.text = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - item.text:SetPoint("LEFT", 4, 0) - item:SetScript("OnEnter", function(s) s.text:SetTextColor(1, 1, 1) end) - item:SetScript("OnLeave", function(s) s.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end) - item:SetScript("OnClick", function(s) - s.onClick() - menu:Hide() - bar:Refresh() - onChange() - end) - menuRows[used] = item - end - item:ClearAllPoints() - item:SetPoint("TOPLEFT", 4, y) - item:SetPoint("TOPRIGHT", -4, y) - item.text:SetText(label) - item.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - item.onClick = onClick - item:Show() - y = y - 20 - end - -- "Inherit (Global)" — only while editing a raid auto-layout. Clears the - -- layout's preset override so it follows your global preset. - if IsEditingLayout() then - AddItem(L["Inherit (Global)"], function() - if DF.InheritLayoutDesignerPreset then DF:InheritLayoutDesignerPreset(kind) end - end) - end - for _, name in ipairs(DF:ListDesignerPresets(kind)) do - AddItem(name, function() DF:SetModeDesignerPreset(kind, getMode(), name) end) - end - menu:SetHeight(-y + 4) - end - ddBtn:SetScript("OnClick", function() - if menu:IsShown() then menu:Hide() else BuildMenu(); menu:Show() end - end) - - -- SHARING MARKER. A template can be pointed at by the other mode, a pinned - -- set or an auto layout, and editing it then changes every one of them — - -- which nothing on this bar used to say. The dropdown is a fixed 150px, so - -- the FACT rides as a glyph and the NAMES go in the tooltip, which is free. - -- - -- Deliberately NOT clickable. Splitting a shared template off for this mode - -- is exactly what Duplicate does, two buttons to the right, and Duplicate - -- also lets you name the copy. - local shareIcon = ddBtn:CreateTexture(nil, "OVERLAY") - shareIcon:SetSize(12, 12) - shareIcon:SetPoint("RIGHT", arrow, "LEFT", -3, 0) - shareIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync") - shareIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - shareIcon:Hide() - - -- Read off the REFS, not the party/raid sync flag: sharing by hand (picking - -- the other mode's preset from this dropdown) counts exactly the same, and a - -- ref can't fall out of step with itself. - local function SharedWith() - if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then - return {} -- inheriting: this bar isn't sitting on a preset of its own - end - return (DF.ListDesignerPresetUsers and DF:ListDesignerPresetUsers(kind, CurrentName(), getMode())) or {} - end - - -- The consumers (Party/Raid, Auto Layouts, Pinned Frames) are spread across - -- three other pages, so naming them is the one thing this tooltip has to do - -- — the bar itself already shows what a template is. "can" holds for all - -- four: Auto Layouts and Pinned Frames may inherit their mode's instead - -- (a nil ref), and Party/Raid resolve to a default when nothing is set. - -- Names match their page titles so they are findable. - ddBtn:SetScript("OnEnter", function(self) - local lines = { - L["Templates can be used by Party, Raid, Auto Layouts and Pinned Frames."], - } - local shared = SharedWith() - if #shared > 0 then - lines[#lines + 1] = " " - lines[#lines + 1] = { text = format(L["Also used by: %s"], table.concat(shared, ", ")), accent = true } - -- "there" points back at the list above, so the consequence needs no - -- nouns of its own. It has to be said: a shared template's edits - -- reach a screen you are not looking at, and naming the users is - -- only the fact, not the warning. - lines[#lines + 1] = L["Edits apply there too."] - end - GUI:ShowTooltip(self, { title = L["Templates"], lines = lines }) - end) - ddBtn:SetScript("OnLeave", function() GUI:HideTooltip() end) - - -- When editing a raid auto-layout, default the NEW preset name to the - -- layout's name (e.g. editing "31-40" → prefill "31-40") so making a - -- per-layout preset is one click + Enter. nil (blank) otherwise. (Duplicate - -- names after its source preset, not the layout.) - local function EditingLayoutName() - if not IsEditingLayout() then return nil end -- mode-gated (raid only) - local apu = DF.AutoProfilesUI - if apu and apu.editingProfile then - return apu.editingProfile.name - end - return nil - end - - -- Action buttons. opts.iconButtons = true swaps the labeled buttons for - -- compact tooltipped icon-only buttons (22x22) — used where the bar shares - -- a row with other controls (Aura Designer header). Default stays labeled - -- (Text Designer) so existing callers are untouched. - local function CreateAction(labelText, iconName, width, onClick) - if opts.iconButtons then - local b = CreateFrame("Button", nil, bar, "BackdropTemplate") - GUI:StyleButton(b, { - width = 22, height = 22, - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, - size = 14, color = C_TEXT, - }, - }) - b:SetScript("OnClick", function(self) - onClick(self) - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - b:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = labelText}) - end) - b:HookScript("OnLeave", function() GUI:HideTooltip() end) - return b - end - return GUI:CreateButton(bar, labelText, width, 22, onClick) - end - - local newBtn = CreateAction(L["New"], "add", 48, function() - PromptPresetName(L["Name the new template:"], EditingLayoutName() or "", L["Create"], function(text) - local n = DF:CreateDesignerPreset(kind, text) - if n then - DF:SetModeDesignerPreset(kind, getMode(), n) - bar:Refresh(); onChange() - end - end) - end) - newBtn:SetPoint("LEFT", ddBtn, "RIGHT", 6, 0) - - local dupBtn = CreateAction(L["Duplicate"], "content_copy", 72, function() - local cur = CurrentName() - -- Duplicate defaults to " copy" (New uses the layout name, but a - -- duplicate is of a specific preset, so name it after the source). - PromptPresetName(L["Name the duplicated template:"], cur .. " copy", L["Duplicate"], function(text) - local n = DF:DuplicateDesignerPreset(kind, cur, text) - if n then - DF:SetModeDesignerPreset(kind, getMode(), n) - bar:Refresh(); onChange() - end - end) - end) - dupBtn:SetPoint("LEFT", newBtn, "RIGHT", 4, 0) - - local renameBtn = CreateAction(L["Rename"], "edit", 62, function() - local cur = CurrentName() - if cur == DF.DEFAULT_PRESET then return end - PromptPresetName(L["Rename template:"], cur, L["Rename"], function(text) - DF:RenameDesignerPreset(kind, cur, text) - bar:Refresh(); onChange() - end) - end) - renameBtn:SetPoint("LEFT", dupBtn, "RIGHT", 4, 0) - - local delBtn = CreateAction(L["Delete"], "delete", 56, function() - local cur = CurrentName() - if cur == DF.DEFAULT_PRESET then return end - ConfirmDeletePreset(kind, cur, function() bar:Refresh(); onChange() end) - end) - delBtn:SetPoint("LEFT", renameBtn, "RIGHT", 4, 0) - - local function SetActionEnabled(btn, on) - if on then - btn:Enable() - if btn.Text then btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end - if btn.Icon then btn.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end - else - btn:Disable() - -- greyed: Default can't be renamed/deleted - if btn.Text then btn.Text:SetTextColor(0.4, 0.4, 0.4) end - if btn.Icon then btn.Icon:SetVertexColor(0.4, 0.4, 0.4) end - end - end - - function bar:Refresh() - ddBtn.text:SetText(CurrentLabel()) - -- Make room for the share glyph only while it's up, so an unshared - -- preset keeps the full label width. - local isShared = #SharedWith() > 0 - shareIcon:SetShown(isShared) - ddBtn.text:ClearAllPoints() - ddBtn.text:SetPoint("LEFT", 6, 0) - ddBtn.text:SetPoint("RIGHT", isShared and -30 or -16, 0) - -- Rename/Delete act on the resolved preset; disable for the non-editable - -- Default and while a layout is inheriting (you're following the global, - -- not sitting on a layout-specific preset). - local inheriting = IsEditingLayout() and DF.IsLayoutDesignerInheriting - and DF:IsLayoutDesignerInheriting(kind) - local canModify = (CurrentName() ~= DF.DEFAULT_PRESET) and not inheriting - SetActionEnabled(renameBtn, canModify) - SetActionEnabled(delBtn, canModify) - end - - bar:Refresh() - -- The sharing glyph reflects OTHER refs (the other mode, a pinned set, an - -- auto layout), which can change without changing THIS mode's — and both - -- designer pages early-return their rebuild when their own preset is - -- unchanged, so the glyph would sit stale. Register the live bar so the - -- shared page refresh can reach it. One slot per kind: a rebuilt page - -- overwrites its own entry, and a torn-down bar is hidden, so nothing - -- accumulates. - GUI._designerPresetBars = GUI._designerPresetBars or {} - GUI._designerPresetBars[kind] = bar - return bar -end - -function GUI:RefreshDesignerPresetBars() - for _, bar in pairs(GUI._designerPresetBars or {}) do - if bar.Refresh and bar:IsShown() then bar:Refresh() end - end -end - --- Creates a button with an icon and text --- iconName is the name of the icon file (without path/extension) --- iconSize is optional (defaults to 16) --- align: "center" (default) or "left". Pass "left" for wide / full-width --- list-style buttons where centred content floats (see GUI:StyleButton). -function GUI:CreateIconButton(parent, iconName, text, width, height, func, iconSize, align) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = width or 120, height = height or 22, - text = text, - align = align, - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, - size = iconSize or 18, - color = C_TEXT, - }, - }) - - btn:SetScript("OnClick", function(self) - if func then func(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - return btn -end - --- Creates a \"See Also:\" section with clickable links to related pages --- links = { {pageId = \"display_tooltips\", label = \"Tooltips\"}, ... } -function GUI:CreateSeeAlso(parent, links) - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - container:SetHeight(32) - -- The page's own footer: pinned to the bottom of the viewport on a SHORT - -- page instead of floating wherever the content happened to end. See the - -- footer block in the page layout. - container.isPageFooter = true - CreateElementBackdrop(container, { - bgColor = { 0.1, 0.1, 0.1, 0.5 }, - borderColor = { 0.3, 0.3, 0.3, 0.8 }, - }) - - local label = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("TOPLEFT", 8, -10) - label:SetText(L["See Also:"]) - label:SetTextColor(0.7, 0.7, 0.7) - - local linkButtons = {} - local separators = {} - - for i, linkData in ipairs(links) do - local link = CreateFrame("Button", nil, container) - link:SetHeight(16) - - local linkText = link:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - linkText:SetPoint("TOPLEFT", 0, -1) - linkText:SetText(linkData.label) - local c = GetThemeColor() - linkText:SetTextColor(c.r, c.g, c.b) - link.text = linkText - link.textWidth = linkText:GetStringWidth() + 4 - link:SetWidth(link.textWidth) - - link:SetScript("OnEnter", function(self) - local h = GUI:LinkHoverColor(c) - linkText:SetTextColor(h.r, h.g, h.b) - end) - link:SetScript("OnLeave", function(self) - linkText:SetTextColor(c.r, c.g, c.b) - end) - link:SetScript("OnClick", function() - if GUI.SelectTab then - GUI.SelectTab(linkData.pageId) - end - end) - - table.insert(linkButtons, link) - - -- Create separator (hidden by default, shown as needed) - if i < #links then - local sep = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - sep:SetText("•") - sep:SetTextColor(0.5, 0.5, 0.5) - table.insert(separators, sep) - end - end - - -- Layout function that handles wrapping - local function LayoutLinks() - local containerWidth = container:GetWidth() - if containerWidth < 50 then return end -- Not sized yet - - local labelWidth = label:GetStringWidth() + 16 - local firstLinkX = labelWidth -- Where first link starts - local xOffset = labelWidth - local yOffset = -9 - local lineHeight = 18 - local maxX = containerWidth - 10 - local rowCount = 1 - - -- First pass: determine which links are on which row - local linkRows = {} - local tempX = labelWidth - local currentRow = 1 - - for i, link in ipairs(linkButtons) do - local linkWidth = link.textWidth - local sepWidth = (i < #linkButtons) and 14 or 0 - - -- Check if we need to wrap - if tempX + linkWidth > maxX and tempX > labelWidth then - currentRow = currentRow + 1 - tempX = firstLinkX - end - - linkRows[i] = currentRow - tempX = tempX + linkWidth + sepWidth - end - - rowCount = currentRow - - -- Second pass: position elements - xOffset = labelWidth - local lastRowForLink = 1 - - for i, link in ipairs(linkButtons) do - local linkWidth = link.textWidth - - -- Check if we need to wrap to new line - if linkRows[i] > lastRowForLink then - xOffset = firstLinkX - yOffset = yOffset - lineHeight - lastRowForLink = linkRows[i] - end - - link:ClearAllPoints() - link:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset) - - xOffset = xOffset + linkWidth + 2 - - -- Position separator only if next link is on same row - if separators[i] then - if linkRows[i + 1] == linkRows[i] then - separators[i]:ClearAllPoints() - separators[i]:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset - 1) - separators[i]:Show() - xOffset = xOffset + 12 - else - separators[i]:Hide() - end - end - end - - -- Adjust container height based on rows. - -- - -- Snapped HERE, where the number is computed, because this widget - -- MEASURES ITSELF: LayoutLinks runs from OnSizeChanged and from a - -- C_Timer.After(0), i.e. a frame AFTER the page layout has run. - -- Correcting the height after the fact cannot win -- whatever sets a - -- grid-aligned height, this function overwrites it on the next frame, - -- and the bar's bottom edge ends up split across two device rows. - -- Measured: 28 units at 1.40625 px/unit is 39.375px, 0.375 off. - local newHeight = SnapLen(container, 10 + (rowCount * lineHeight)) - container:SetHeight(newHeight) - container.layoutHeight = newHeight + 5 - end - - container:SetScript("OnSizeChanged", LayoutLinks) - - -- Initial layout after a frame (to let width be set) - C_Timer.After(0, LayoutLinks) - - return container -end - --- ========================================================================= --- OVERRIDE INDICATORS FOR AUTO PROFILES --- ========================================================================= --- Helper function to add override indicators (star, reset button, global value text) --- to widget containers when editing an auto profile - --- Debug flag - when true, shows all reset buttons regardless of override state -S.overrideDebugMode = false - --- Track all widgets with override indicators for refresh -local overrideWidgets = {} - --- Function to check if debug mode is active (exposed for other files) -local function IsOverrideDebugMode() - return S.overrideDebugMode -end -GUI.IsOverrideDebugMode = IsOverrideDebugMode - --- Function to refresh all override indicators -local function RefreshAllOverrideIndicators() - for _, widget in ipairs(overrideWidgets) do - if widget and widget.UpdateOverrideIndicators then - widget:UpdateOverrideIndicators() - end - end - -- Also refresh position override indicator - if GUI.UpdatePositionOverrideIndicator then - GUI.UpdatePositionOverrideIndicator() - end - -- Refresh tab override stars (auto-profiles) - if DF.AutoProfilesUI and DF.AutoProfilesUI.RefreshTabOverrideStars then - DF.AutoProfilesUI:RefreshTabOverrideStars() - end -end -GUI.RefreshAllOverrideIndicators = RefreshAllOverrideIndicators - --- Allow other files to register widgets with override indicators -function GUI.RegisterOverrideWidget(widget) - table.insert(overrideWidgets, widget) -end - --- NOT a dump, despite what the old description ("Auto layout override table --- dump") claimed — it prints no table. It forces every reset button / override --- marker visible regardless of override state, so you can see which controls --- carry the machinery at all. S.overrideDebugMode is live: read by --- GUI.IsOverrideDebugMode and three marker call sites. -DF:RegisterDebugSlash("DFOVERRIDEDEBUG", "Force-show every override marker and reset button", true, "/dfoverridedebug") -SlashCmdList["DFOVERRIDEDEBUG"] = function() - S.overrideDebugMode = not S.overrideDebugMode - DF:Say("Override debug mode " .. (S.overrideDebugMode and "ENABLED" or "DISABLED")) - -- Refresh all override indicators - RefreshAllOverrideIndicators() - -- Also update position panel if open - if DF.positionPanel and DF.positionPanel.UpdatePositionOverride then - DF.positionPanel.UpdatePositionOverride() - end -end - --- ============================================================ --- SHARED OVERRIDE CONTROLS --- One "override active" marker (a coloured dot) + one "reset to global" button --- (red, icon-only, danger tone — reads like the Reset Page button), so every --- override control across the addon speaks one visual language. Callers create --- them, position the returned frames, and toggle Show/Hide. --- ============================================================ --- Single source of truth for the override-marker colour. -GUI.OVERRIDE_MARKER_COLOR = { 1, 0.8, 0.2 } - --- A coloured dot marking "this setting is overridden". Returns a hidden Button --- so the caller can set .tooltipText / .tooltipSubText for a hover tooltip. --- `size` = dot diameter in px (default 12); the hit frame is a touch larger. -function GUI:CreateOverrideMarker(parent, size) - size = size or 12 - local c = GUI.OVERRIDE_MARKER_COLOR - local btn = CreateFrame("Button", nil, parent) - btn:SetSize(size + 6, size + 6) - -- Only ever a hover-tooltip target; let clicks fall through so a marker - -- placed on a clickable parent (e.g. a nav tab) doesn't eat its clicks. - -- SetPropagateMouseClicks is PROTECTED on 12.1 (ADDON_ACTION_BLOCKED if called - -- under combat lockdown — e.g. opening/refreshing settings in combat); skip it - -- there. Only matters when the marker sits on a clickable parent, and combat - -- GUI edits are the rare case. - if btn.SetPropagateMouseClicks and not InCombatLockdown() then btn:SetPropagateMouseClicks(true) end - local icon = btn:CreateTexture(nil, "OVERLAY") - icon:SetPoint("CENTER") - icon:SetSize(size, size) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\dot") - icon:SetVertexColor(c[1], c[2], c[3]) - btn.icon = icon - btn:SetScript("OnEnter", function(s) - if s.tooltipText then - GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipSubText and { s.tooltipSubText } or nil }) - end - end) - btn:SetScript("OnLeave", function() GUI:HideTooltip() end) - btn:Hide() - return btn -end - --- A bare checkbox sized for a LIST ROW — no label, no db binding, no settings-row --- geometry. GUI:CreateCheckbox is a whole 30px settings row with its own label and --- hit rect, which is the wrong shape entirely inside a 22px pooled list row that --- already owns its own text, count and selection accent. --- --- The caller drives it: :SetChecked(bool) to paint, opts.onClick to react. It does --- NOT read or write the db itself, because a list row's meaning changes per bind --- (a pooled row is a different filter every refresh) and a captured dbKey would go --- stale the moment the pool rebinds. --- --- ⚠ Clicks deliberately do NOT propagate. The override marker above lets them fall --- through because it is a passive marker; this is a control, and on a clickable row --- the two gestures must stay separate — tick the box to switch the filter on, click --- anywhere else to select it. Nothing here calls SetPropagateMouseClicks, which is --- PROTECTED on 12.1 anyway (see the note in CreateOverrideMarker). --- --- ⚠ The BOX ITSELF is GUI:StyleCheckButton, the addon's one checkbox look — do not --- hand-roll it again. This was hand-rolled once and drifted four ways: it drew the --- Media\Icons\check GLYPH where every other checkbox in the addon draws a filled --- WHITE8x8 square (a different SYMBOL, not a different style), it skipped PixelUtil --- so it alone was unsnapped, it had no hover wash, and it recoloured its BORDER when --- checked, which nothing else does. CreateDebugCategoryRow is the precedent for this --- exact case — a list row with a checkbox — at the same size. --- --- manualCheck because this is a plain Button, not a CheckButton: SetChecked below --- drives the mark. A real CheckButton would draw its checked mark through the native --- checked state, which has no disabled-checked texture here — so a greyed-but-ticked --- row (every filter row while All Buffs is on) would lose its tick entirely. --- --- opts: { size = 16, checkSize = 9, onClick = function(checked) end, --- tooltip = title, tooltipDesc = line } -function GUI:CreateRowToggle(parent, opts) - opts = opts or {} - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleCheckButton(btn, { - size = opts.size or 16, - checkSize = opts.checkSize or 9, - manualCheck = true, - }) - - btn.checked = false - function btn:SetChecked(on) - self.checked = on and true or false - -- Re-tint on every paint. StyleCheckButton registers its theme listener on - -- this button's PARENT, which for a pooled list row is a frame inside a - -- scroll child that the page's theme walk never visits (same trap as - -- StyleButton's wash). The list rebinds every row on refresh, and a refresh - -- is what a mode switch produces, so painting the accent here is what - -- actually carries party purple -> raid orange. - self.ApplyThemeColor(GetThemeColor()) - self.Check:SetShown(self.checked) - end - - btn:SetScript("OnClick", function(s) - if s.onClick then s.onClick(not s.checked) end - end) - btn:SetScript("OnEnter", function(s) - if s.tooltipText then - GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipDesc and { s.tooltipDesc } or nil }) - end - end) - btn:SetScript("OnLeave", function() GUI:HideTooltip() end) - - btn.onClick = opts.onClick - btn.tooltipText = opts.tooltip - btn.tooltipDesc = opts.tooltipDesc - btn:SetChecked(false) - return btn -end - --- A "reset to global" button — red, icon-only, danger tone (matches the Reset --- Page button). Returns a hidden Button. opts: { size = 18, tooltip = title, --- tooltipDesc = line, onClick = fn }. -function GUI:CreateOverrideResetButton(parent, opts) - opts = opts or {} - local size = opts.size or 18 - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = size, height = size, - tone = "danger", - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = size - 6 }, - }) - btn:Hide() - -- StyleButton owns OnEnter (hover wash); hook the tooltip on top. - btn:HookScript("OnEnter", function(self) - if opts.tooltip then - GUI:ShowTooltip(self, { title = opts.tooltip, lines = opts.tooltipDesc and { opts.tooltipDesc } or nil }) - end - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - if opts.onClick then btn:SetScript("OnClick", opts.onClick) end - return btn -end - -local function AddOverrideIndicators(container, lbl, dbKey, onReset, verticalOffset, optionsMap, dbTable) - -- Skip for proxy tables (e.g. Aura Designer) that don't support per-key override tracking - if dbTable and rawget(dbTable, "_skipOverrideIndicators") then return end - verticalOffset = verticalOffset or 0 - container.overrideOptionsMap = optionsMap - - -- Reset button (red, icon-only) at top-right; the override marker (dot) - -- sits to its left. Both are shared helpers (GUI:CreateOverride*). - local resetBtn = GUI:CreateOverrideResetButton(container, { - tooltip = L["Reset to Global"], - tooltipDesc = L["Reset this setting to its global value."], - onClick = function() if onReset then onReset() end end, - }) - resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, verticalOffset) - container.overrideResetBtn = resetBtn - - local starBtn = GUI:CreateOverrideMarker(container) - starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) - container.overrideStar = starBtn - - -- Global value text (shown when in edit mode) - positioned inline after label - local globalText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - globalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - globalText:SetTextColor(0.4, 0.4, 0.4) - globalText:Hide() - container.overrideGlobalText = globalText - - -- Checkmark icon for matching global value - local checkIcon = container:CreateTexture(nil, "OVERLAY") - checkIcon:SetSize(8, 8) - checkIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\check") - checkIcon:SetVertexColor(0.3, 0.7, 0.3) - checkIcon:Hide() - container.overrideCheckIcon = checkIcon - - -- Store dbKey for reference - container.overrideDbKey = dbKey - - -- Function to update override indicators - container.UpdateOverrideIndicators = function(self, currentValue) - -- Debug mode shows all buttons - if S.overrideDebugMode then - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideGlobalText:SetText("(debug)") - self.overrideGlobalText:SetTextColor(1, 0.8, 0.2) -- Yellow for visibility - self.overrideGlobalText:Show() - self.overrideCheckIcon:Hide() - return - end - - -- Only show when in raid mode - local GUI = DF.GUI - if not GUI or GUI.SelectedMode ~= "raid" then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide() - self.overrideCheckIcon:Hide() - return - end - - local AutoProfilesUI = DF.AutoProfilesUI - local isEditing = AutoProfilesUI and AutoProfilesUI:IsEditing() - local isRuntimeOverridden = AutoProfilesUI and AutoProfilesUI:IsOverriddenByRuntime(dbKey) - - -- Hide everything if not editing AND not runtime-overridden - if not isEditing and not isRuntimeOverridden then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide() - self.overrideCheckIcon:Hide() - return - end - - -- Runtime override mode: show star + global value, but no reset button - if isRuntimeOverridden and not isEditing then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting is being overridden by the active auto layout profile. To change it, edit the profile in the Auto Layouts tab."] - self.overrideStar:Show() - self.overrideResetBtn:Hide() -- Can't reset runtime overrides from controls - self.overrideCheckIcon:Hide() - - local globalValue = AutoProfilesUI:GetRuntimeGlobalValue(dbKey) - - -- Format global value for display - local globalDisplay - if type(globalValue) == "boolean" then - globalDisplay = globalValue and L["Yes"] or L["No"] - elseif type(globalValue) == "number" then - if globalValue == math.floor(globalValue) then - globalDisplay = tostring(globalValue) - else - globalDisplay = string.format("%.2f", globalValue) - end - elseif type(globalValue) == "table" then - if globalValue.r then - globalDisplay = L["Color"] - else - globalDisplay = "..." - end - elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then - local mapped = self.overrideOptionsMap[globalValue] - if type(mapped) == "table" then - globalDisplay = mapped.text or mapped.label or globalValue - else - globalDisplay = tostring(mapped) - end - else - globalDisplay = tostring(globalValue or L["None"]) - end - - self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) - self.overrideGlobalText:ClearAllPoints() - self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) - self.overrideGlobalText:Show() - return - end - - -- Editing mode: existing behavior - -- Check if setting is overridden - local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) - local globalValue = AutoProfilesUI:GetGlobalValue(dbKey) - - -- Show/hide star and reset button - if isOverridden then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] - self.overrideStar:Show() - self.overrideResetBtn:Show() - else - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - end - - -- Format global value for display - local globalDisplay - if type(globalValue) == "boolean" then - globalDisplay = globalValue and L["Yes"] or L["No"] - elseif type(globalValue) == "number" then - if globalValue == math.floor(globalValue) then - globalDisplay = tostring(globalValue) - else - globalDisplay = string.format("%.2f", globalValue) - end - elseif type(globalValue) == "table" then - -- Color table - if globalValue.r then - globalDisplay = L["Color"] - else - globalDisplay = "..." - end - elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then - local mapped = self.overrideOptionsMap[globalValue] - if type(mapped) == "table" then - globalDisplay = mapped.text or mapped.label or globalValue - else - globalDisplay = tostring(mapped) - end - else - globalDisplay = tostring(globalValue or L["None"]) - end - - -- Show global value inline with label - self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) - self.overrideGlobalText:ClearAllPoints() - self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - - if isOverridden then - self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) - self.overrideCheckIcon:Hide() - else - self.overrideGlobalText:SetTextColor(0.3, 0.6, 0.3) - -- Position check icon after text - self.overrideCheckIcon:ClearAllPoints() - self.overrideCheckIcon:SetPoint("LEFT", self.overrideGlobalText, "RIGHT", 2, 0) - self.overrideCheckIcon:Show() - end - self.overrideGlobalText:Show() - end - - -- Register this widget for refresh tracking - table.insert(overrideWidgets, container) - - return container -end -P.AddOverrideIndicators = AddOverrideIndicators - --- Override indicators for order list controls (drag lists) --- These don't have traditional labels, so we use a compact star + reset + "Modified" badge -local function AddOrderListOverrideIndicators(container, dbKey, onReset) - -- Reset button (red, icon-only) + override marker (dot) — shared helpers. - local resetBtn = GUI:CreateOverrideResetButton(container, { - tooltip = L["Reset to Global Order"], - onClick = function() if onReset then onReset() end end, - }) - resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 14) - container.overrideResetBtn = resetBtn - - local starBtn = GUI:CreateOverrideMarker(container) - starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) - container.overrideStar = starBtn - local starIcon = starBtn.icon -- the "Modified" badge below anchors to the dot - - -- "Modified" text to the left of star - local modifiedText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - modifiedText:SetPoint("RIGHT", starIcon, "LEFT", -2, 0) - modifiedText:SetText(L["Modified"]) - modifiedText:SetTextColor(1, 0.8, 0.2, 0.8) - modifiedText:Hide() - container.overrideModifiedText = modifiedText - - -- Store dbKey for reference - container.overrideDbKey = dbKey - - -- Update function - container.UpdateOverrideIndicators = function(self, currentValue) - -- Debug mode - if S.overrideDebugMode then - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideModifiedText:SetText("Modified (debug)") - self.overrideModifiedText:Show() - return - end - - -- Only show when in raid mode and editing - local GUI = DF.GUI - if not GUI or GUI.SelectedMode ~= "raid" then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideModifiedText:Hide() - return - end - - local AutoProfilesUI = DF.AutoProfilesUI - if not AutoProfilesUI or not AutoProfilesUI:IsEditing() then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideModifiedText:Hide() - return - end - - local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) - - if isOverridden then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideModifiedText:Show() - else - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideModifiedText:Hide() - end - end - - -- Register for refresh tracking - table.insert(overrideWidgets, container) -end -P.AddOrderListOverrideIndicators = AddOrderListOverrideIndicators - --- ============================================================ --- SHARED CHECK / RADIO LOOK — single source of truth --- Applies the standard square look to a CheckButton: element --- backdrop + a pixel-snapped, themed WHITE8x8 check. Every box --- and radio (the full builders below AND the hand-rolled ones --- elsewhere) should call this, so a restyle is ONE edit. --- opts.size box size (default 18) --- opts.checkSize check-square size (default 10) --- opts.accent fixed tint {r,g,b} — e.g. ClickCasting's green. --- Omit to follow the party/raid theme (and auto- --- register a theme listener on opts.themeRoot). --- opts.themeRoot frame whose .ThemeListeners drive recolor --- (default the button's parent); only used when --- no accent is given. --- Returns the check texture (also stored as cb.Check). --- ============================================================ -function GUI:StyleCheckButton(cb, opts) - opts = opts or {} - PixelUtil.SetSize(cb, opts.size or 18, opts.size or 18) - CreateElementBackdrop(cb) - - local check = cb.Check or cb:CreateTexture(nil, "OVERLAY") - check:SetTexture("Interface\\Buttons\\WHITE8x8") - local cs = opts.checkSize or 10 - PixelUtil.SetSize(check, cs, cs) - PixelUtil.SetPoint(check, "CENTER", cb, "CENTER", 0, 0) - - local accent = opts.accent - local col = accent or GetThemeColor() - cb.Check = check - -- Native checkboxes let WoW show/hide the check via the checked state; a few - -- consumers (and plain Button-based pseudo-checkboxes) drive it manually via - -- cb.Check:SetShown(). opts.manualCheck supports those without SetCheckedTexture. - if opts.manualCheck then - check:Hide() - else - cb:SetCheckedTexture(check) - end - - -- Hover feedback: a subtle accent wash on the native HIGHLIGHT layer. WoW - -- shows it on mouseover automatically, so it works regardless of any OnEnter - -- the consumer sets (no clobbering), and it doesn't recolor the 1px border - -- (which can render unevenly at fractional UI scales). - local hl = cb:CreateTexture(nil, "HIGHLIGHT") - hl:SetTexture("Interface\\Buttons\\WHITE8x8") - hl:SetAllPoints(cb) - cb.Highlight = hl - - -- Single source for the themed tint: check colour + hover-wash strength. - -- Consumers that drive their own theme refresh call cb.ApplyThemeColor(c) too, - -- so the wash alpha (0.35) is defined in exactly one place. - cb.ApplyThemeColor = function(c) - check:SetVertexColor(c.r, c.g, c.b) - hl:SetVertexColor(c.r, c.g, c.b, 0.35) - end - cb.ApplyThemeColor(col) - - if not accent then - cb.UpdateTheme = function() - cb.ApplyThemeColor(GetThemeColor()) - end - local root = opts.themeRoot or cb:GetParent() - if root then - root.ThemeListeners = root.ThemeListeners or {} - table.insert(root.ThemeListeners, cb) - end - end - return check -end - -function GUI:CreateCheckbox(parent, label, dbTable, dbKey, callback, customGet, customSet, overrideKey) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(220, 24) - container.preferredHeight = GUI.RowHeight.checkbox -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "checkbox" -- /df debug gapcheck groups the spacing report by this - container.fixedRowHeight = true - - local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") - cb:SetPoint("LEFT", 0, 0) - -- Box + themed check come from the shared styler (single source of truth). - GUI:StyleCheckButton(cb, { themeRoot = parent }) - - -- Label - local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - txt:SetText(label) - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - container.label = txt -- exposed so callers can re-font / anchor a subtitle - - -- Determine the key to use for override indicators - local effectiveOverrideKey = overrideKey or dbKey - - -- Add override indicators if we have a key (either dbKey or overrideKey) - if effectiveOverrideKey and type(effectiveOverrideKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(effectiveOverrideKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(effectiveOverrideKey) - cb:SetChecked(globalVal) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - elseif customSet then - customSet(globalVal) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(container, txt, effectiveOverrideKey, onReset, nil, nil, dbTable) - end - - local function UpdateState() - local val = false - if customGet then val = customGet() elseif dbTable and dbKey then val = dbTable[dbKey] end - cb:SetChecked(val) - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - - container:SetScript("OnShow", UpdateState) - -- Re-read the source and repaint the box, for a caller that changed the value - -- behind the widget's back (a "set all" button). Same contract as - -- CreateSegmentToggle:Refresh(); before this, call sites reached for a - -- Hide()/Show() bounce to fire the OnShow above. - container.Refresh = UpdateState - cb:SetScript("OnClick", function(self) - local val = self:GetChecked() - -- Was gated on DF.debugEnabled and printed straight to CHAT, bypassing the - -- console entirely. GUI is the right category and it is already declared. - DF:Debug("GUI", "checkbox OnClick: dbKey=%s overrideKey=%s value=%s", - tostring(dbKey), tostring(overrideKey), tostring(val)) - - -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(effectiveOverrideKey, val) then - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end - return - end - - if customSet then customSet(val) elseif dbTable and dbKey then dbTable[dbKey] = val end - - -- If editing a profile, also set the override (use effectiveOverrideKey) - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and effectiveOverrideKey then - DF.AutoProfilesUI:SetProfileSetting(effectiveOverrideKey, val) - end - - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - - if callback then - DF:Debug("GUI", "checkbox OnClick: calling callback") - callback() - end - if parent.RefreshStates then - DF:Debug("GUI", "checkbox OnClick: calling RefreshStates") - parent:RefreshStates() - end - DF:Debug("GUI", "checkbox OnClick: calling DF:UpdateAll") - DF:UpdateAll() - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget (box + check fill + label) so a disabled CHECKED - -- box greys too: native SetEnabled has no DisabledCheckedTexture, so the - -- accent check would otherwise stay full-bright. - self:SetAlpha(enabled and 1 or 0.4) - cb:SetEnabled(enabled) - if enabled then - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The - -- earlier hit-rect arithmetic here is gone with it — the hit frame is anchored - -- to the FontString, so a label overflowing the fixed 220 container is covered - -- for free rather than by widening the container's hit rect to match. - GUI:AttachTooltip(container, label, txt) - - UpdateState() - - -- SEARCH: Register this setting - if DF.Search then - local hasCustomGetSet = (customGet ~= nil or customSet ~= nil) - if dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterCheckbox(label, dbKey, nil, false, callback) - elseif hasCustomGetSet then - container.searchEntry = DF.Search:RegisterCheckbox(label, nil, nil, true, callback) - end - end - - return container -end - --- ============================================================ --- SEGMENT TOGGLE --- A compact segmented control: the labels sit ON the buttons, all --- of them boxed inside one recessed track so the pair reads as a --- single control rather than two loose buttons. This is the --- "Border Mode: [Shared][Custom]" idiom (AuraDesigner/Options.lua) --- with the track added; use it for short mutually-exclusive values --- that want to sit next to the field they qualify (s / %). --- --- API: GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) --- segments : ordered { value =, label =, tooltip = } — label is what --- shows on the button, tooltip the full name behind a terse one --- opts.segmentWidth (26) / opts.height (18) --- opts.fallbackValue : treated as selected when the stored value matches --- no segment, so an unset key still lights the right button --- opts.customGet / opts.customSet : same convention as CreateCheckbox / --- CreateSlider / CreateDropdown — for a toggle over TRANSIENT UI --- state (or one with its own save path) rather than a db key. Pass --- dbTable/dbKey as nil when using these. --- Returns the container with :Refresh(), :refreshContent() and :SetEnabled(). --- ============================================================ -function GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) - opts = opts or {} - local segW = opts.segmentWidth or 26 - local h = opts.height or 18 - local pad = 1 -- track lip around the buttons - - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - container:SetSize(segW * #segments + pad * 2, h + pad * 2) - CreateElementBackdrop(container) -- the recessed track behind every segment - - -- One read/write pair for both pathways, so the click handler and Refresh - -- can't drift apart. Explicit ifs, not `a and b or c` — a stored value of - -- false/nil is legitimate. - local function GetValue() - if opts.customGet then return opts.customGet() end - if dbTable and dbKey then return dbTable[dbKey] end - end - local function SetValue(v) - if opts.customSet then opts.customSet(v) return true end - if dbTable and dbKey then dbTable[dbKey] = v return true end - return false - end - - local buttons = {} - for i, seg in ipairs(segments) do - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - GUI:StyleButton(btn, { width = segW, height = h, text = seg.label }) - GUI:SetSettingsFont(btn.Text, 9, "") - btn:SetPoint("TOPLEFT", container, "TOPLEFT", pad + (i - 1) * segW, -pad) - btn.value = seg.value - if seg.tooltip then - btn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = seg.tooltip, lines = opts.tooltipLines }) - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - btn:SetScript("OnClick", function(self) - if GetValue() == self.value then return end - if not SetValue(self.value) then return end - container:Refresh() - if callback then callback(self.value) end - end) - buttons[i] = btn - end - - -- Selection: the shared accent border/fill via SetActive, plus a bright/dim - -- label so the state still reads at a glance in a themed accent that is close - -- to the resting border colour. - function container:Refresh() - local cur = GetValue() - local matched = false - for _, b in ipairs(buttons) do if b.value == cur then matched = true end end - if not matched then cur = opts.fallbackValue end - for _, b in ipairs(buttons) do - local on = (b.value == cur) - b:SetActive(on) - if b.Text then - local c = on and C_TEXT or C_TEXT_DIM - b.Text:SetTextColor(c.r, c.g, c.b) - end - end - end - container.refreshContent = function(self) self:Refresh() end - - container.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - for _, b in ipairs(buttons) do b:EnableMouse(enabled) end - end - - container.UpdateTheme = function() container:Refresh() end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, container) - - container:Refresh() - return container -end - - --- ============================================================ --- DEBUG CATEGORY ROW --- A wide row with checkbox + bold category name + description. --- The whole row is clickable, hover shows a background highlight, --- and the description is also surfaced as a tooltip on hover so it --- remains accessible even if it gets visually truncated. --- --- Used by the Debug > Categories sub-tab. The categoryKey writes --- directly to DandersFramesDB_v2.debug.filters. --- ============================================================ --- opts.noisy marks a firehose category: one user action can produce dozens of --- lines, which evicts the trace the log was opened to capture. It renders as the --- shared caution icon with its own tooltip rather than a "(noisy)" suffix baked --- into the description string -- the suffix was untranslatable in place, and it --- competed with the description for the same line of text. -function GUI:CreateDebugCategoryRow(parent, categoryKey, description, width, noisy) - local row = CreateFrame("Frame", nil, parent) - row:SetSize(width or 520, 28) - row:EnableMouse(true) - - -- Hover background - row.hoverBg = row:CreateTexture(nil, "BACKGROUND") - row.hoverBg:SetAllPoints() - row.hoverBg:SetColorTexture(1, 1, 1, 0.05) - row.hoverBg:Hide() - - -- Checkbox - local cb = CreateFrame("CheckButton", nil, row, "BackdropTemplate") - cb:SetPoint("LEFT", 4, 0) - GUI:StyleCheckButton(cb, { size = 16, checkSize = 9, themeRoot = parent }) - cb:EnableMouse(false) -- forward clicks to the row - - -- Category name (bold, full opacity) - local nameTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlight") - nameTxt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - nameTxt:SetWidth(86) - nameTxt:SetJustifyH("LEFT") - nameTxt:SetText(categoryKey) - nameTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Caution icon for a firehose category. Its own hit area, so it can carry a - -- different tooltip from the row without stealing the row's click: the frame - -- only enables mouse, it has no OnMouseUp, so a click still falls through to - -- the row underneath and toggles the category like anywhere else on it. - local noisyIcon - if noisy then - noisyIcon = CreateFrame("Frame", nil, row) - noisyIcon:SetSize(14, 14) - noisyIcon:SetPoint("RIGHT", row, "RIGHT", -8, 0) - noisyIcon:EnableMouse(true) - noisyIcon:SetFrameLevel(row:GetFrameLevel() + 2) - local tex = noisyIcon:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints() - tex:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning") - -- The caution tone's ICON colour, read straight from the shared tone table - -- so this stays in step with every banner and note that uses it. - local ic = INFO_BANNER_TONES.caution.iconColor - tex:SetVertexColor(ic[1], ic[2], ic[3]) - noisyIcon:SetScript("OnEnter", function(self) - -- Keep the row's wash up: the pointer is still over the row, and - -- letting it drop would read as the row losing focus. - row.hoverBg:Show() - GUI:ShowTooltip(self, { - title = L["Noisy category"], - lines = { L["This category can fill the log very quickly, burying the entries you are looking for."], - L["Turn it on only while reproducing the bug it relates to."] }, - tone = "caution", - }) - end) - noisyIcon:SetScript("OnLeave", function() - row.hoverBg:Hide() - GUI:HideTooltip() - end) - row.noisyIcon = noisyIcon - end - - -- Description (dim, fills remaining space, wraps if too long) - if description and description ~= "" then - local descTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - descTxt:SetPoint("LEFT", nameTxt, "RIGHT", 12, 0) - -- Stop short of the icon rather than running under it. - if noisyIcon then - descTxt:SetPoint("RIGHT", noisyIcon, "LEFT", -6, 0) - else - descTxt:SetPoint("RIGHT", row, "RIGHT", -8, 0) - end - descTxt:SetJustifyH("LEFT") - descTxt:SetText(description) - descTxt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - row.descTxt = descTxt - end - - -- State helpers — read/write filters[categoryKey] - -- Absent or true = logged, explicit false = not logged - row.RefreshState = function() - local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters - local checked = (not filters) or filters[categoryKey] ~= false - cb:SetChecked(checked) - end - - local function ToggleState() - local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters - if not filters then return end - -- Toggle: false -> true, anything else -> false - if filters[categoryKey] == false then - filters[categoryKey] = true - else - filters[categoryKey] = false - end - row.RefreshState() - if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end - end - - row:SetScript("OnMouseUp", function(_, button) - if button == "LeftButton" then ToggleState() end - end) - - row:SetScript("OnEnter", function(self) - self.hoverBg:Show() - if description and description ~= "" then - GUI:ShowTooltip(self, { title = categoryKey, lines = { description } }) - end - end) - row:SetScript("OnLeave", function(self) - self.hoverBg:Hide() - GUI:HideTooltip() - end) - - row:SetScript("OnShow", row.RefreshState) - row.RefreshState() - - return row -end - --- The one input "well": translucent-black fill + dim edge. Shared by StyleEditBox --- (the single-line field) and CreateTextArea (the scrolling multi-line container) --- so a text area and a text field read as the same control at two sizes. Passed --- straight to CreateElementBackdrop, which only reads them. -local INPUT_FILL = { 0, 0, 0, 0.5 } -local INPUT_EDGE = { 0.3, 0.3, 0.3, 1 } - --- StyleEditBox: normalize a bare (label-less) EditBox to the standard input --- chrome used by CreateInput/CreateEditBox — translucent-black fill + dim border --- + standard font/insets. The caller still owns size/position/scripts. Pass --- opts.skipFont to keep a custom font (e.g. multi-line / monospace inputs). -function GUI:StyleEditBox(eb, opts) - opts = opts or {} - CreateElementBackdrop(eb, { - bgColor = INPUT_FILL, - borderColor = INPUT_EDGE, - }) - if not opts.skipFont then - eb:SetFontObject(DFFontHighlightSmall) - eb:SetTextInsets(5, 5, opts.multiline and 5 or 0, opts.multiline and 5 or 0) - end - -- Multiline mode: for text areas (export/import blobs, macro bodies). The - -- caller owns the ScrollFrame/sizing; this just flags the editbox + relaxes - -- the vertical insets. Enter inserts a newline (no auto clear-focus). - if opts.multiline then - eb:SetMultiLine(true) - eb:SetAutoFocus(false) - end - return eb -end - -function GUI:CreateInput(parent, label, width) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 180, 44) - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local editbox = CreateFrame("EditBox", nil, frame) - -- Two-corner anchored, so the offset and the height are the ONLY levers -- - -- Nothing corrects a frame's position at runtime, and controls are not - -- position-corrected at all any more. Snap both and all four edges land. - local ebY = SnapLen(editbox, -15) or -15 - editbox:SetPoint("TOPLEFT", 0, ebY) - editbox:SetPoint("TOPRIGHT", 0, ebY) - editbox:SetHeight(SnapLen(editbox, 24) or 24) - GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets - editbox:SetAutoFocus(false) - editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - editbox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) - - -- Grey-when-disabled parity with CreateEditBox (cheap insurance if ever placed - -- in a gated group): dim the whole widget + block editing. - frame.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - editbox:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - frame.EditBox = editbox - -- Tooltip: shared attach on the LABEL only. This factory carried no tooltip - -- support at all, so a caller that set .tooltip on it got silence — - -- Options.lua's custom range-spell input did exactly that, and its - -- explanation never appeared. Keeping it off the edit box also means it can't - -- cover what you are typing. - GUI:AttachTooltip(frame, label, lbl) - return frame -end - --- CreateEditBox: Text input with db binding (for settings like custom text) -function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 180, 44) - frame.preferredHeight = GUI.RowHeight.editbox -- factory-owned slot height (see GUI.RowHeight) - frame.rowKind = "editbox" - frame.fixedRowHeight = true - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - end - if frame.EditBox then - frame.EditBox:SetText(globalVal or "") - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(frame, lbl, dbKey, onReset, 6, nil, dbTable) - end - - local editbox = CreateFrame("EditBox", nil, frame) - -- Two-corner anchored, so the offset and the height are the ONLY levers -- - -- Nothing corrects a frame's position at runtime, and controls are not - -- position-corrected at all any more. Snap both and all four edges land. - local ebY = SnapLen(editbox, -15) or -15 - editbox:SetPoint("TOPLEFT", 0, ebY) - editbox:SetPoint("TOPRIGHT", 0, ebY) - editbox:SetHeight(SnapLen(editbox, 24) or 24) - GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets - editbox:SetAutoFocus(false) - - -- Set initial value from db - if dbTable and dbKey then - editbox:SetText(dbTable[dbKey] or "") - end - - -- Save on enter or focus lost - local function SaveValue() - if dbTable and dbKey then - local val = editbox:GetText() - -- Runtime override protection - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then - if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(val) end - return - end - dbTable[dbKey] = val - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - DF.AutoProfilesUI:SetProfileSetting(dbKey, val) - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(val) - end - if callback then callback() end - end - end - - editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - editbox:SetScript("OnEnterPressed", function(self) - SaveValue() - self:ClearFocus() - end) - editbox:SetScript("OnEditFocusLost", SaveValue) - - -- Optional placeholder: greyed example text shown while the box is empty - -- and unfocused. Purely cosmetic — never written to the db. - if placeholder and placeholder ~= "" then - local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") - ph:SetPoint("LEFT", 5, 0) - ph:SetPoint("RIGHT", -5, 0) - ph:SetJustifyH("LEFT") - ph:SetText(placeholder) - ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) - local function UpdatePlaceholder() - ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") - end - -- Exposed so a caller that puts something INSIDE the box (see - -- GUI:AddEditBoxIcon) can move the placeholder clear of it. - editbox.Placeholder = ph - editbox.UpdatePlaceholder = UpdatePlaceholder - editbox:HookScript("OnTextChanged", UpdatePlaceholder) - editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) - editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) - UpdatePlaceholder() - end - - -- Refresh override indicators on show - frame:SetScript("OnShow", function() - if dbTable and dbKey then - editbox:SetText(dbTable[dbKey] or "") - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end - end) - - -- Grey-when-disabled: the grey loop (RefreshChildStates) calls widget:SetEnabled, - -- but this frame had none, so a disabled group left the input full-bright AND - -- editable. Dim the whole widget + block editing, matching the other helpers. - frame.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - editbox:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - frame.EditBox = editbox - return frame -end - --- ============================================================ --- LEADING ICON INSIDE AN EDIT BOX --- The search-bar look from the main addon search (Features/Search.lua), made --- available to any CreateEditBox rather than re-rolled per search field: the --- glyph, the same 0.72 grey, and — the part that is easy to forget — the text --- inset AND the placeholder both moved clear of it, so neither the typed text --- nor the "Search..." hint runs underneath the icon. --- --- Pass frame.EditBox, not the frame. --- ============================================================ -function GUI:AddEditBoxIcon(editbox, texture, size) - if not editbox or not texture then return end - size = size or 14 - local icon = editbox:CreateTexture(nil, "OVERLAY") - icon:SetSize(size, size) - icon:SetPoint("LEFT", 6, 0) - icon:SetTexture(texture) - icon:SetVertexColor(0.72, 0.72, 0.72) - editbox.Icon = icon - - local left = 6 + size + 5 - local _, right, top, bottom = editbox:GetTextInsets() - editbox:SetTextInsets(left, right, top, bottom) - if editbox.Placeholder then - editbox.Placeholder:SetPoint("LEFT", left, 0) - end - return icon -end - --- ============================================================ --- TEXT AREA — the multi-line cousin of CreateEditBox --- A bordered well holding a scrolling multi-line EditBox. Eight surfaces built --- this same container + ScrollFrame + EditBox stack by hand (export/import blobs, --- the debug log viewer and script runner, the macro body, the popup's input mode, --- the changelog), each picking its own well colour and three of them forgetting --- the click-to-focus, so clicking the empty space below the text did nothing. --- One owner, and the same well as every single-line input. --- --- opts: --- width, height size the well; omit and anchor it yourself --- text initial contents --- fontObject default DFFontHighlightSmall --- fontSize, fontFlags use the settings font at a size instead of a font object --- maxLetters --- readOnly show-and-copy (export strings, the changelog): user --- edits bounce back, but it stays selectable + copyable --- autoFocus take focus and select all on creation (copy-me popups) --- onTextChanged(text, userInput) --- onEscape(editBox) default: clear focus --- bgColor, borderColor override the standard input well --- plain skip the well entirely — for a text area that fills a --- surface which already has its own panel (the changelog) --- insets text insets, default 4 --- Returns the well, with .EditBox / .ScrollFrame and SetText / GetText / --- HighlightText / SetFocus / ClearFocus / SetEnabled forwarded to the field. --- ============================================================ -local TEXTAREA_PAD = 4 -- gap between the well's edge and the scroll frame -local TEXTAREA_GUTTER = 18 -- room right of the scroll for the themed scrollbar - -function GUI:CreateTextArea(parent, opts) - opts = opts or {} - - local well = CreateFrame("Frame", nil, parent, "BackdropTemplate") - if opts.width and opts.height then well:SetSize(opts.width, opts.height) end - local pad = opts.plain and 0 or TEXTAREA_PAD - if not opts.plain then - CreateElementBackdrop(well, { - bgColor = opts.bgColor or INPUT_FILL, - borderColor = opts.borderColor or INPUT_EDGE, - }) - end - - local scroll = CreateFrame("ScrollFrame", nil, well, "ScrollFrameTemplate") - scroll:SetPoint("TOPLEFT", pad, -pad) - scroll:SetPoint("BOTTOMRIGHT", -(pad + TEXTAREA_GUTTER), pad) - StyleScrollBar(scroll) - - local eb = CreateFrame("EditBox", nil, scroll) - eb:SetMultiLine(true) - eb:SetAutoFocus(false) - if opts.fontSize then - GUI:SetSettingsFont(eb, opts.fontSize, opts.fontFlags or "") - else - eb:SetFontObject(opts.fontObject or DFFontHighlightSmall) - end - eb:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - local inset = opts.insets or (opts.plain and 0 or TEXTAREA_PAD) - eb:SetTextInsets(inset, inset, inset, inset) - if opts.maxLetters then eb:SetMaxLetters(opts.maxLetters) end - eb:SetScript("OnEscapePressed", opts.onEscape or function(s) s:ClearFocus() end) - scroll:SetScrollChild(eb) - - -- A scroll child has to be told its size, and that is only known once the - -- well has been sized or its anchors have resolved — which for an anchored - -- (rather than SetSize'd) well is not this frame. So do it here AND on every - -- resize, which is also what lets a text area sit in a resizable window - -- without the caller re-setting the width by hand on every show. - -- - -- Height is seeded ONCE and then left alone: after that the field owns it, - -- growing its own rect as text is added, which is what makes the scroll - -- frame scroll. Re-seeding on resize would clamp a grown field back down. - local heightSeeded = false - local function SyncSize(w, h) - if w and w > 0 then eb:SetWidth(w) end - if h and h > 0 and not heightSeeded then - heightSeeded = true - eb:SetHeight(h) - end - end - scroll:SetScript("OnSizeChanged", function(_, w, h) SyncSize(w, h) end) - SyncSize(scroll:GetWidth(), scroll:GetHeight()) - - -- Clicking anywhere in the well lands in the field, not just on the text - -- itself — the contents rarely fill the box. - well:EnableMouse(true) - well:SetScript("OnMouseDown", function() eb:SetFocus() end) - scroll:EnableMouse(true) - scroll:SetScript("OnMouseDown", function() eb:SetFocus() end) - - well.EditBox, well.ScrollFrame = eb, scroll - well.GetText = function(_) return eb:GetText() end - well.HighlightText = function(_, ...) eb:HighlightText(...) end - well.SetFocus = function(_) eb:SetFocus() end - well.ClearFocus = function(_) eb:ClearFocus() end - well.SetText = function(_, text) - eb:SetText(text or "") - eb:SetCursorPosition(0) -- long blobs open at the top, not the tail - end - - -- Grey-when-disabled, per the GUI conventions: dim the whole widget AND stop - -- it accepting edits. - well.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - eb:SetEnabled(enabled) - end - - -- WoW has no read-only EditBox. Bouncing the text back on any USER change - -- keeps Ctrl+A / Ctrl+C working, which EnableKeyboard(false) would not. - if opts.readOnly then - local locked = opts.text or "" - eb:SetScript("OnTextChanged", function(s, user) - if user and s:GetText() ~= locked then - s:SetText(locked) - s:HighlightText() - end - end) - well.SetText = function(_, text) - locked = text or "" - eb:SetText(locked) - eb:SetCursorPosition(0) - end - elseif opts.onTextChanged then - eb:SetScript("OnTextChanged", function(s, user) - opts.onTextChanged(s:GetText(), user) - end) - end - - if opts.text then well:SetText(opts.text) end - if opts.autoFocus then - eb:SetAutoFocus(true) - eb:SetFocus() - eb:HighlightText() - end - - return well -end - --- customGet / customSet (optional, matches CreateDropdown's pattern): when --- provided, the slider routes its reads and writes through these functions --- instead of dbTable[dbKey] directly. Used by widgets whose underlying value --- lives inside a nested table (e.g. Border Alpha → BorderColor.a), --- where the plain `dbTable[dbKey] = v` path can't express the nesting. --- Consumers that pass customSet typically pass dbKey = nil so the --- auto-profile override system doesn't track a key that doesn't exist at the --- top level of dbTable. --- accentColor (optional {r,g,b}): fixed thumb/fill colour instead of the mode --- theme — for ClickCasting (green) / Search (blue) which keep their identity. - -function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, callback, lightweightUpdate, usePreviewMode, customGet, customSet, accentColor) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 50) - container.preferredHeight = GUI.RowHeight.slider -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "slider" - container.fixedRowHeight = true - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided (for auto profiles) - -- Use vertical offset of 6 to align with label row (sliders have input box below) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - dbTable[dbKey] = globalVal - -- Update slider display - if container.slider then - container.slider:SetValue(globalVal) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) - end - - -- Background track. - -- Left edge and height here; the RIGHT edge is pinned to the value box further - -- down, once that exists. The track used to be a fixed 180px while a dropdown - -- anchors TOPLEFT+TOPRIGHT and fills its container, so on any panel wider than - -- the 260 default the two controls ended at visibly different x positions -- - -- and drifted further apart the wider the panel got. Both are container-driven - -- now, so they line up at any width instead of at one magic number. - local track = CreateFrame("Frame", nil, container, "BackdropTemplate") - track:SetPoint("TOPLEFT", 0, SnapLen(track, -18) or -18) - track:SetHeight(SnapLen(track, 8) or 8) - CreateElementBackdrop(track) - - -- Fill track (colored portion) - local fill = track:CreateTexture(nil, "ARTWORK") - fill:SetPoint("LEFT", 1, 0) - fill:SetHeight(6) - local c = accentColor or GetThemeColor() - fill:SetColorTexture(c.r, c.g, c.b, 0.8) - - -- Slider - local slider = CreateFrame("Slider", nil, container) - -- Same snapped offset/height as the track it sits on, or the invisible hit - -- area drifts off the visible bar by a fraction of a pixel. - slider:SetPoint("TOPLEFT", 0, SnapLen(slider, -18) or -18) - slider:SetHeight(SnapLen(slider, 8) or 8) -- right edge pinned to the value box, same as the track - slider:SetOrientation("HORIZONTAL") - slider:SetMinMaxValues(minVal, maxVal) - slider:SetValueStep(step) - slider:SetObeyStepOnDrag(true) - slider:SetHitRectInsets(-4, -4, -8, -8) - container.slider = slider -- Store reference for reset - - -- Track whether this slider is actively being dragged - local isDragging = false - - -- Store preview mode flag for this slider - local sliderUsePreviewMode = usePreviewMode or false - - -- Thumb - local thumb = slider:CreateTexture(nil, "OVERLAY") - thumb:SetSize(12, 16) - thumb:SetColorTexture(c.r, c.g, c.b, 1) - slider:SetThumbTexture(thumb) - - -- Value input - local input = CreateFrame("EditBox", nil, container, "BackdropTemplate") - input:SetSize(50, 20) - -- Pinned to the container's RIGHT edge -- the same edge a dropdown's opener - -- ends on -- and the track/slider then stretch from the left to meet it. - -- y = -12 keeps the 20px box centred on the 8px track at -18. - input:SetPoint("TOPRIGHT", 0, -12) - track:SetPoint("RIGHT", input, "LEFT", -8, 0) - slider:SetPoint("RIGHT", input, "LEFT", -8, 0) - CreateElementBackdrop(input) - input:SetFontObject(DFFontHighlightSmall) - input:SetJustifyH("CENTER") - input:SetAutoFocus(false) - input:SetTextInsets(2, 2, 0, 0) - - local function UpdateFill() - local val = slider:GetValue() - local pct = (val - minVal) / (maxVal - minVal) - -- Measured off the LIVE track, not the old hardcoded 178 (= the fixed 180 - -- track minus the fill's 1px inset each side). The track stretches now, so - -- a constant here would under-fill on any panel wider than the default. - local usable = (track:GetWidth() or 0) - 2 - if usable < 1 then usable = 1 end - fill:SetWidth(math.max(1, pct * usable)) - end - -- The track's width is only known once the page layout has resolved its - -- anchors, and changes again if the panel is resized -- so repaint the fill - -- whenever it does, or the bar renders at its pre-layout width. - track:SetScript("OnSizeChanged", function() UpdateFill() end) - - container.SetEnabled = function(self, enabled) - slider:SetEnabled(enabled) - -- Grey the numeric value box too: it was only EnableMouse'd (clicks blocked - -- but still full-bright + typeable), so it stayed lit while the track dimmed. - input:EnableMouse(enabled) - input:SetEnabled(enabled) - input:SetAlpha(enabled and 1 or 0.4) - local tc = accentColor or GetThemeColor() - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - thumb:SetColorTexture(tc.r, tc.g, tc.b, 1) - fill:SetColorTexture(tc.r, tc.g, tc.b, 0.8) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - thumb:SetColorTexture(0.4, 0.4, 0.4, 1) - fill:SetColorTexture(0.4, 0.4, 0.4, 0.5) - end - end - - container.UpdateTheme = function() - local nc = accentColor or GetThemeColor() - if slider:IsEnabled() then - thumb:SetColorTexture(nc.r, nc.g, nc.b, 1) - fill:SetColorTexture(nc.r, nc.g, nc.b, 0.8) - end - end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, container) - - local suppressCallback = false - - -- Smart format: show whole numbers as integers, decimals with minimum precision needed - local function FormatValue(val) - if val == math.floor(val) then - return string.format("%d", val) - elseif val * 10 == math.floor(val * 10) then - return string.format("%.1f", val) - else - return string.format("%.2f", val) - end - end - - -- Wrapper for both pathways: customGet/Set when provided, dbTable[dbKey] - -- otherwise. Centralising this avoids a sprinkling of `if customGet then` - -- across every place the slider touches its value. - local function ReadValue() - if customGet then return customGet() end - if dbTable then return dbTable[dbKey] end - return nil - end - local function WriteValue(v) - if customSet then return customSet(v) end - if dbTable then dbTable[dbKey] = v end - end - - local function UpdateValue(val) - val = val or minVal - suppressCallback = true - slider:SetValue(val) - suppressCallback = false - input:SetText(FormatValue(val)) - UpdateFill() - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - - -- Re-read the bound value (customGet or dbTable[dbKey]) and redraw. For a slider whose - -- source can change under it — e.g. one bound through customGet to whichever key an - -- account-wide unit dial currently selects — called from refreshContent. - container.RefreshValue = function(self) - local v = ReadValue() - if v ~= nil then UpdateValue(math.max(minVal, math.min(maxVal, v))) end - end - -- Runtime range. A slider whose UNIT is decided elsewhere (seconds vs percent) is built - -- once but must re-scale in place, or flipping that dial leaves a 1-60 track in front of - -- a percentage until the panel is rebuilt. Pair with container.label for the caption. - container.SetRange = function(self, newMin, newMax) - if newMin ~= minVal or newMax ~= maxVal then - minVal, maxVal = newMin, newMax - slider:SetMinMaxValues(minVal, maxVal) - end - self:RefreshValue() - end - - -- Track drag start - pass the lightweight update function, name for debug, and preview mode - slider:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - isDragging = true - local funcName = lightweightUpdate and ((dbKey or label or "slider") .. " lightweight") or nil - DF:OnSliderDragStart(lightweightUpdate, funcName, sliderUsePreviewMode) - end - end) - - -- Track drag end - do full update when slider is released - slider:SetScript("OnMouseUp", function(self, button) - if button == "LeftButton" and isDragging then - isDragging = false - DF:OnSliderDragStop() - -- Update override indicators after drag ends - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(slider:GetValue()) - end - end - end) - - slider:SetScript("OnShow", function() - local v = ReadValue() - if v ~= nil then UpdateValue(v) end - end) - - slider:SetScript("OnValueChanged", function(self, value) - if suppressCallback then return end - if not (dbTable or customSet) then return end - if step >= 1 then - value = math.floor(value + 0.5) - else - value = math.floor(value / step + 0.5) * step - end - - -- Runtime override protection: redirect to baseline, skip refresh - if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, value) then - if not input:HasFocus() then input:SetText(FormatValue(value)) end - UpdateFill() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(value) end - return - end - - WriteValue(value) - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, value) - end - - if not input:HasFocus() then - input:SetText(FormatValue(value)) - end - UpdateFill() - -- Use targeted update system - lightweight during drag, full on release - DF:ThrottledUpdateAll() - -- Skip callback during drag - it will run via UpdateAll on release - if callback and not DF.sliderDragging then - callback() - end - end) - - input:SetScript("OnEnterPressed", function(self) - local val = tonumber(self:GetText()) - if val then - val = math.max(minVal, math.min(maxVal, val)) - - -- Runtime override protection: redirect to baseline, skip refresh - if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then - self:SetText(FormatValue(val)) - suppressCallback = true - slider:SetValue(val) - suppressCallback = false - UpdateFill() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end - self:ClearFocus() - return - end - - WriteValue(val) - suppressCallback = true - slider:SetValue(val) - suppressCallback = false - - -- Update input text to show actual value entered - self:SetText(FormatValue(val)) - UpdateFill() - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, val) - end - - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - - -- FIX 2025-01-20: Call callback OR lightweightUpdate (some sliders have nil callback) - if callback then - callback() - elseif lightweightUpdate then - lightweightUpdate() - end - - -- Guaranteed full update (SetValue may not fire OnValueChanged if value didn't change) - DF:UpdateAll() - else - local v = ReadValue(); if v ~= nil then UpdateValue(v) end - end - self:ClearFocus() - end) - - input:SetScript("OnEscapePressed", function(self) - local v = ReadValue(); if v ~= nil then UpdateValue(v) end - self:ClearFocus() - end) - - local initial = ReadValue() - if initial ~= nil then UpdateValue(initial) end - - -- SEARCH: Register this setting with slider metadata - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterSlider(label, dbKey, minVal, maxVal, step, nil, callback) - end - - -- Expose label for dynamic updates - container.label = lbl - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). Keeping it - -- off the bar matters most here — a tooltip over a slider you are dragging is - -- the worst case of the problem. Both .tooltip (title from the label) and the - -- legacy .tooltipText/.tooltipSubText pair are honoured. - GUI:AttachTooltip(container, label, lbl) - - return container -end - --- Stamp the shared Frame Level explanation onto a slider. One helper rather than the same --- two strings at 21 call sites, and it keeps the wording in ONE place -- the old per-page --- label went stale the moment the scale changed (it still read "0=Auto" afterwards). --- Takes the CONTAINER that CreateSlider returns, which is what every call site has. -function GUI:SetFrameLevelTooltip(container) - if not container then return end - container.tooltipText = L["Frame Level"] - container.tooltipSubText = L["Higher numbers draw on top of lower ones. Every Frame Level in DandersFrames uses the same scale, counted up from the unit frame, so you can compare them directly."] - return container -- chainable, so it wraps a CreateSlider call in place -end - --- Dual-handle range slider: two draggable handles select a [lo, hi] sub-range of --- [minRange, maxRange]. Self-contained — the caller anchors the returned track --- frame and reads values via the onChange callback. (:GetValues() also exists and --- completes the SetValues pair, but no current consumer polls it.) Drag is --- tracked on the track's own OnUpdate (no dependence on parent scripts), and a --- mouse-button check releases the drag even if the cursor leaves the handle. --- opts: --- width(336), accent({r,g,b}=theme), minRange, maxRange, lo, hi, --- scaleLabels({...} optional tick labels), scaleMin/scaleMax (label scale, --- default minRange/maxRange — lets ticks stay on a fixed scale while the --- handle range changes), display(FontString updated each change), --- formatRange(fn(lo,hi)->str), formatOne(fn(v)->str), --- onChange(fn(lo,hi) — fired on user-driven changes only, not SetValues). --- Methods on the returned frame: :SetRange(min,max), :SetValues(lo,hi), --- :GetValues()->lo,hi. -function GUI:CreateRangeSlider(parent, opts) - opts = opts or {} - local width = opts.width or 336 - local accent = opts.accent or GetThemeColor() - - local track = CreateFrame("Frame", nil, parent, "BackdropTemplate") - track:SetSize(width, 12) - CreateElementBackdrop(track, { - bgColor = { 0.03, 0.03, 0.03, 1 }, - borderColor = { 0.2, 0.2, 0.2, 1 }, - }) - - track.minRange = opts.minRange or 1 - track.maxRange = opts.maxRange or 40 - track.lo = opts.lo or track.minRange - track.hi = opts.hi or track.maxRange - - local rangeFill = track:CreateTexture(nil, "ARTWORK") - rangeFill:SetTexture("Interface\\Buttons\\WHITE8x8") - rangeFill:SetVertexColor(accent.r, accent.g, accent.b, 0.5) - rangeFill:SetHeight(10) - rangeFill:SetPoint("TOP", 0, -1) - - local function MakeHandle() - local h = CreateFrame("Button", nil, track) - h:SetSize(8, 16) - h:EnableMouse(true) - local tex = h:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints() - tex:SetTexture("Interface\\Buttons\\WHITE8x8") - tex:SetVertexColor(accent.r, accent.g, accent.b, 1) - return h - end - local minHandle, maxHandle = MakeHandle(), MakeHandle() - track.minHandle, track.maxHandle = minHandle, maxHandle - - local function ValueToPos(value) - local pct = (value - track.minRange) / (track.maxRange - track.minRange) - return pct * (width - 4) + 2 - end - local function PosToValue(pos) - local pct = (pos - 2) / (width - 4) - return math.floor(pct * (track.maxRange - track.minRange) + track.minRange + 0.5) - end - - local function Redraw() - local minPos, maxPos = ValueToPos(track.lo), ValueToPos(track.hi) - minHandle:ClearAllPoints() - minHandle:SetPoint("CENTER", track, "LEFT", minPos, 0) - maxHandle:ClearAllPoints() - maxHandle:SetPoint("CENTER", track, "LEFT", maxPos, 0) - rangeFill:ClearAllPoints() - rangeFill:SetPoint("LEFT", track, "LEFT", minPos, 0) - rangeFill:SetWidth(math.max(maxPos - minPos, 2)) - if opts.display then - if track.lo == track.hi then - opts.display:SetText(opts.formatOne and opts.formatOne(track.lo) or tostring(track.lo)) - else - opts.display:SetText(opts.formatRange and opts.formatRange(track.lo, track.hi) - or (track.lo .. " - " .. track.hi)) - end - end - end - - local dragging = nil - local function ApplyCursor() - local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() - local trackLeft = track:GetLeft() - if not trackLeft then return end - local pos = math.max(2, math.min(x - trackLeft, width - 2)) - local value = math.max(track.minRange, math.min(PosToValue(pos), track.maxRange)) - if dragging == "min" then - if value <= track.hi then track.lo = value end - elseif dragging == "max" then - if value >= track.lo then track.hi = value end - end - Redraw() - if opts.onChange then opts.onChange(track.lo, track.hi) end - end - - minHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "min" end end) - maxHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "max" end end) - track:SetScript("OnUpdate", function() - if not dragging then return end - if not IsMouseButtonDown("LeftButton") then dragging = nil; return end - ApplyCursor() - end) - - track:EnableMouse(true) - track:SetScript("OnMouseDown", function(_, b) - if b ~= "LeftButton" then return end - local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() - local trackLeft = track:GetLeft() - if not trackLeft then return end - local value = PosToValue(x - trackLeft) - if math.abs(value - track.lo) <= math.abs(value - track.hi) then - if value <= track.hi then track.lo = math.max(track.minRange, value) end - else - if value >= track.lo then track.hi = math.min(track.maxRange, value) end - end - Redraw() - if opts.onChange then opts.onChange(track.lo, track.hi) end - end) - - if opts.scaleLabels then - local sMin = opts.scaleMin or track.minRange - local sMax = opts.scaleMax or track.maxRange - for _, num in ipairs(opts.scaleLabels) do - local pct = (num - sMin) / (sMax - sMin) - local xPos = pct * (width - 4) + 2 - local lbl = track:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetText(num) - lbl:SetTextColor(0.35, 0.35, 0.35) - lbl:SetPoint("TOP", track, "BOTTOM", xPos - width / 2, -2) - end - end - - function track:SetRange(minR, maxR) - self.minRange, self.maxRange = minR, maxR - self.lo = math.max(minR, math.min(self.lo, maxR)) - self.hi = math.max(minR, math.min(self.hi, maxR)) - Redraw() - end - function track:SetValues(lo, hi) - self.lo = math.max(self.minRange, math.min(lo, self.maxRange)) - self.hi = math.max(self.minRange, math.min(hi, self.maxRange)) - Redraw() - end - function track:GetValues() return self.lo, self.hi end - - Redraw() - return track -end - -function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback, lightweightCallback, useLightweight) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 28) - container.preferredHeight = GUI.RowHeight.colorpicker -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "colorpicker" - container.fixedRowHeight = true - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, 0) - btn:SetPoint("TOPRIGHT", 0, 0) - btn:SetHeight(SnapLen(btn, 24) or 24) - CreateElementBackdrop(btn) - - -- Label - local txt = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", 8, 0) - txt:SetText(label) - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Color swatch - local swatch = btn:CreateTexture(nil, "OVERLAY") - swatch:SetSize(40, 16) - swatch:SetPoint("RIGHT", -6, 0) - - -- Add override indicators if dbKey is provided (for auto profiles) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if globalVal then - dbTable[dbKey].r = globalVal.r - dbTable[dbKey].g = globalVal.g - dbTable[dbKey].b = globalVal.b - dbTable[dbKey].a = globalVal.a or 1 - end - if container.UpdateSwatch then - container:UpdateSwatch() - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable[dbKey]) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, txt, dbKey, onReset, nil, nil, dbTable) - end - - local function UpdateSwatch() - if dbTable and dbKey and dbTable[dbKey] then - local c = dbTable[dbKey] - swatch:SetColorTexture(c.r, c.g, c.b, c.a or 1) - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(c) - end - end - end - container.UpdateSwatch = UpdateSwatch -- Expose for reset - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - -- Tooltip: shared attach on the LABEL only. This factory carried none, across - -- 87 colour pickers. The btn keeps its own hover scripts untouched — the hit - -- frame is over the text, not the swatch. - GUI:AttachTooltip(container, label, txt) - - btn:SetScript("OnClick", function() - if not dbTable then return end - local c = dbTable[dbKey] - if not c then - c = {r = 1, g = 1, b = 1, a = 1} - dbTable[dbKey] = c - end - - -- Store original values for cancel - local originalColor = {r = c.r, g = c.g, b = c.b, a = c.a or 1} - - -- Blizzard's SetupColorPickerAndShow fires swatchFunc once DURING - -- setup (its SetColorRGB triggers OnColorSelect — the source comments - -- it). That spurious fire re-writes the unchanged colour and runs the - -- change callbacks on mere open: it commits per-element override flags - -- (Text Designer) and triggers a pointless full refresh. Suppress - -- callbacks until setup has returned. - local settingUp = true - - local info = { - swatchFunc = function() - if settingUp then return end - local r, g, b = ColorPickerFrame:GetColorRGB() - local a = 1 - if hasAlpha and ColorPickerFrame.GetColorAlpha then - a = ColorPickerFrame:GetColorAlpha() or 1 - end - dbTable[dbKey].r = r - dbTable[dbKey].g = g - dbTable[dbKey].b = b - dbTable[dbKey].a = a - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, {r = r, g = g, b = b, a = a}) - end - - UpdateSwatch() - -- Use lightweight callback during dragging if available - if useLightweight and lightweightCallback then - lightweightCallback() - else - DF:ThrottledUpdateAll() - if callback then callback() end - end - end, - hasOpacity = hasAlpha, - opacityFunc = hasAlpha and function() - if settingUp then return end - if ColorPickerFrame.GetColorAlpha then - local a = ColorPickerFrame:GetColorAlpha() - if a then - dbTable[dbKey].a = a - UpdateSwatch() - -- Use lightweight callback during dragging if available - if useLightweight and lightweightCallback then - lightweightCallback() - else - DF:ThrottledUpdateAll() - if callback then callback() end - end - end - end - end or nil, - cancelFunc = function(restore) - -- Restore original color on cancel - dbTable[dbKey].r = originalColor.r - dbTable[dbKey].g = originalColor.g - dbTable[dbKey].b = originalColor.b - dbTable[dbKey].a = originalColor.a - UpdateSwatch() - DF:UpdateAll() - if callback then callback() end - end, - r = c.r or 1, - g = c.g or 1, - b = c.b or 1, - opacity = hasAlpha and (c.a or 1) or nil, - } - - -- Hook the OK button to run full update when confirmed - if useLightweight and lightweightCallback then - -- We need to run full update when picker is closed via OK - -- Use a frame to detect when color picker closes - if not container.colorPickerWatcher then - container.colorPickerWatcher = CreateFrame("Frame") - end - container.colorPickerWatcher:SetScript("OnUpdate", function(self) - if not ColorPickerFrame:IsShown() then - self:SetScript("OnUpdate", nil) - -- Only run if color changed (not cancelled) - local cur = dbTable[dbKey] - if cur.r ~= originalColor.r or cur.g ~= originalColor.g or - cur.b ~= originalColor.b or cur.a ~= originalColor.a then - DF:UpdateAll() - if callback then callback() end - end - end - end) - end - - -- Attach default colour so the picker can offer a Default button - -- dbTable.__dfDefaults is set by callers (e.g. Aura Designer proxies) that - -- store their defaults outside DF.PartyDefaults / DF.RaidDefaults. Read via - -- rawget so proxies' __index doesn't see this lookup as a regular setting. - local defaultVal = (dbTable and rawget(dbTable, "__dfDefaults") and dbTable.__dfDefaults[dbKey]) - or (DF.PartyDefaults and DF.PartyDefaults[dbKey]) - or (DF.RaidDefaults and DF.RaidDefaults[dbKey]) - -- Fallback: power bar colours use WoW's PowerBarColor table as their default - if not defaultVal and PowerBarColor and dbKey then - defaultVal = PowerBarColor[dbKey] - end - if defaultVal and type(defaultVal) == "table" and defaultVal.r then - info.dfDefaultColor = {r = defaultVal.r or 1, g = defaultVal.g or 1, b = defaultVal.b or 1, a = defaultVal.a or 1} - -- Populate ElvUI's "Default" button (ColorPPDefault) so it enables and - -- pastes the DF setting default when the native Blizzard picker is shown - local elvDefault = _G["ColorPPDefault"] - if elvDefault then - elvDefault.colors = info.dfDefaultColor - end - end - - -- Mark this as a DandersFrames color picker call - GUI:MarkColorPickerCall() - ColorPickerFrame:SetupColorPickerAndShow(info) - settingUp = false - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so the colour swatch greys even when it's a dark - -- colour (SetDesaturated alone is invisible on near-black swatches). - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - swatch:SetDesaturated(false) - else - txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - swatch:SetDesaturated(true) - end - end - - btn:SetScript("OnShow", UpdateSwatch) - UpdateSwatch() - - -- SEARCH: Register this setting - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterColorPicker(label, dbKey, hasAlpha, nil, callback) - end - - return container -end - --- ============================================================ --- OPEN-MENU REGISTRY --- Dropdown/preset menus anchor to their button and survive context switches --- that leave the button visible (e.g. the Aura Designer changing tabs). --- Every menu frame registers here at creation; CloseAllMenus() lets a --- context switch dismiss whatever is open. --- ============================================================ -GUI._menus = GUI._menus or {} -function GUI:RegisterMenu(frame) self._menus[frame] = true end -function GUI:CloseAllMenus() - for f in pairs(self._menus) do - if f:IsShown() then f:Hide() end - end -end - -function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, customGet, customSet, opts) - opts = opts or {} - local accentColor = opts.accent - local optionsFunc = opts.optionsFunc - - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, opts.inline and 24 or 50) - -- Inline dropdowns embed in a caller-managed layout (label hidden), so only the standalone - -- form owns a fixed slot height; inline keeps whatever height its host passes. - if not opts.inline then - container.preferredHeight = GUI.RowHeight.dropdown -- factory-owned slot (see GUI.RowHeight) - container.rowKind = "dropdown" - container.fixedRowHeight = true - end - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - -- Expose label so helpers like AddSectionNewBadge can anchor a badge to it. - container.label = lbl - if opts.inline then - lbl:Hide() - end - - -- Add override indicators if dbKey is provided (for auto profiles) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - dbTable[dbKey] = globalVal - if container.UpdateText then - container:UpdateText() - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, lbl, dbKey, onReset, 6, options, dbTable) - end - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - if opts.inline then - -- Fill the container so the caller's SetSize(w, h) controls the opener - -- size + vertical centering (inline callers add their own left label and - -- size the container to match the surrounding row, e.g. 140x18 / 110x16). - btn:SetAllPoints(container) - else - local dY = SnapLen(btn, -16) or -16 - btn:SetPoint("TOPLEFT", 0, dY) - btn:SetPoint("TOPRIGHT", 0, dY) - btn:SetHeight(SnapLen(btn, 24) or 24) - end - CreateElementBackdrop(btn) - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 8, 0) - btn.Text:SetPoint("RIGHT", -20, 0) - btn.Text:SetJustifyH("LEFT") - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Arrow indicator - local arrow = btn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -8, 0) - arrow:SetSize(12, 12) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- SetDisplayOverride: a fixed opener caption that wins over the selected - -- option's text (e.g. a disabled dropdown explaining WHY it's disabled, - -- like the Aura Designer spec dropdown's "shared across specs" state). - -- nil clears the override and restores the selected option's text. - local displayOverride - local function UpdateText() - if displayOverride then - btn.Text:SetText(displayOverride) - return - end - if customGet or (dbTable and dbKey) then - local val = customGet and customGet() or dbTable[dbKey] - local displayVal = options[val] - -- Handle table format: {value = X, text = "text"} or {text = "text"} - local optColor - if type(displayVal) == "table" then - optColor = displayVal.color - displayVal = displayVal.text or displayVal.label or tostring(val) - end - btn.Text:SetText(displayVal or tostring(val) or L["Select..."]) - -- Selected label mirrors its option row's colour when the option - -- carries one (e.g. class-coloured specs); plain options reset to - -- the standard text colour. Skipped while disabled so the - -- grey-when-disabled treatment from SetEnabled stays intact. - if btn:IsEnabled() then - local tc = optColor or C_TEXT - btn.Text:SetTextColor(tc.r, tc.g, tc.b) - end - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - end - container.UpdateText = UpdateText -- Expose for reset - container.SetDisplayOverride = function(self, text) - displayOverride = text - UpdateText() - end - - -- Menu frame - -- Menus hang from the opener's LEFT edge by default, so a wider-than-opener - -- menu spills rightward. opts.menuAlign = "RIGHT" pins the menu's TOPRIGHT - -- to the opener's BOTTOMRIGHT instead (surplus width grows leftward) — for - -- openers sitting near a right edge, e.g. the Aura Designer spec dropdown. - local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") - if opts.menuAlign == "RIGHT" then - menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) - else - menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) - end - menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menuFrame) - menuFrame:SetClampedToScreen(true) - CreateElementBackdrop(menuFrame) - menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) - menuFrame:Hide() - - -- Searchable menus (opts.searchable): a search box pinned above a scrollable - -- item list, mirroring the font/sound dropdowns. Option sets may also carry - -- non-clickable group-header rows — `_order` entries whose option value is - -- { header = true, text = ..., color = ... } (e.g. class-coloured spec - -- groups). While filtering, a header only stays visible if at least one of - -- its options matches. - local searchable = opts.searchable - local ITEM_HEIGHT = 22 - local SEARCH_HEIGHT = 26 - local MAX_VISIBLE = opts.maxVisible or 12 - local searchBox, scrollFrame, scrollChild, searchPlaceholder - if searchable then - searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") - searchBox:SetPoint("TOPLEFT", 4, -4) - searchBox:SetPoint("TOPRIGHT", -4, -4) - searchBox:SetHeight(22) - searchBox:SetAutoFocus(false) - searchBox:SetFontObject(DFFontHighlightSmall) - searchBox:SetTextInsets(24, 8, 0, 0) - CreateElementBackdrop(searchBox) - searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) - - local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") - searchIcon:SetPoint("LEFT", 6, 0) - searchIcon:SetSize(12, 12) - searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") - searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - searchPlaceholder:SetPoint("LEFT", 24, 0) - searchPlaceholder:SetText(L["Search..."]) - searchPlaceholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - - searchBox:SetScript("OnEditFocusGained", function() searchPlaceholder:Hide() end) - searchBox:SetScript("OnEditFocusLost", function() - if searchBox:GetText() == "" then searchPlaceholder:Show() end - end) - searchBox:SetScript("OnEscapePressed", function() menuFrame:Hide() end) - - scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) - scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) - scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollChild:SetWidth(200) -- resized to the menu width on each rebuild - scrollFrame:SetScrollChild(scrollChild) - StyleScrollBar(scrollFrame) - end - - -- Clear tracking when hidden - menuFrame:SetScript("OnHide", function() - if S.currentOpenDropdown == menuFrame then - S.currentOpenDropdown = nil - end - if searchBox then - searchBox:SetText("") - searchBox:ClearFocus() - searchPlaceholder:Show() - end - end) - - local menuButtons = {} - local menuHeight = 0 - local sortedOptions = {} - - -- Build (or rebuild) the menu buttons from the current `options` upvalue. - -- Rows are POOLED (frames can't be garbage-collected) — rebuilds reuse - -- existing buttons and hide the surplus, so dynamic/searchable dropdowns - -- don't leak a row set per rebuild. Static callers build exactly once below. - local menuContentW = 0 -- widest item text; sizes the menu to fit long options - local function BuildMenuButtons(filterText) - for _, b in ipairs(menuButtons) do b:Hide() end - wipe(sortedOptions) - menuHeight = 0 - - -- Collect the ordered option list (header rows ride along) - local ordered = {} - -- Check for custom order array - if options._order then - -- Use specified order - for _, k in ipairs(options._order) do - local v = options[k] - if v then - -- Handle both formats: KEY = "text" or KEY = {value=, text=, color=, header=} - local isTable = type(v) == "table" - table.insert(ordered, { - key = k, - value = isTable and (v.text or v.label or tostring(k)) or v, - color = isTable and v.color or nil, - header = isTable and v.header or nil, - }) - end - end - else - -- Default: sort alphabetically by display value - for k, v in pairs(options) do - local isTable = type(v) == "table" - table.insert(ordered, { - key = k, - value = isTable and (v.text or v.label or tostring(k)) or v, - color = isTable and v.color or nil, - header = isTable and v.header or nil, - }) - end - table.sort(ordered, function(a, b) - local aVal = type(a.value) == "string" and a.value or tostring(a.key) - local bVal = type(b.value) == "string" and b.value or tostring(b.key) - return aVal < bVal - end) - end - - -- Apply the search filter (searchable menus): match option display text; - -- keep a group header only when one of its options survives. - local filter = filterText and filterText ~= "" and filterText:lower() or nil - if filter then - local pendingHeader - for _, opt in ipairs(ordered) do - if opt.header then - pendingHeader = opt - else - local txt = type(opt.value) == "string" and opt.value:lower() or tostring(opt.key):lower() - if txt:find(filter, 1, true) then - if pendingHeader then - table.insert(sortedOptions, pendingHeader) - pendingHeader = nil - end - table.insert(sortedOptions, opt) - end - end - end - else - for _, opt in ipairs(ordered) do - table.insert(sortedOptions, opt) - end - end - - local itemParent = scrollChild or menuFrame - local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) - local selColor = accentColor or GetThemeColor() - -- Once a group header has appeared, subsequent option rows indent under - -- it (menus without headers keep the flat 8px inset). - local seenHeader = false - - for i, opt in ipairs(sortedOptions) do - local menuBtn = menuButtons[i] - if not menuBtn then - menuBtn = CreateFrame("Button", nil, itemParent) - menuBtn:SetPoint("TOPLEFT", 2, -2 - (i - 1) * ITEM_HEIGHT) - menuBtn:SetPoint("TOPRIGHT", -2, -2 - (i - 1) * ITEM_HEIGHT) - menuBtn:SetHeight(ITEM_HEIGHT) - - menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - menuBtn.Text:SetPoint("LEFT", 8, 0) - - -- Subtle separator above group-header rows (hidden on option rows - -- and on a header that is the first visible row) - menuBtn.Sep = menuBtn:CreateTexture(nil, "ARTWORK") - menuBtn.Sep:SetHeight(1) - menuBtn.Sep:SetPoint("TOPLEFT", 4, 1) - menuBtn.Sep:SetPoint("TOPRIGHT", -4, 1) - menuBtn.Sep:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.6) - menuBtn.Sep:Hide() - - menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") - menuBtn.Highlight:SetAllPoints() - - menuBtn:SetScript("OnClick", function(self) - local optKey = self.optKey - -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, optKey) then - UpdateText() - menuFrame:Hide() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(optKey) end - return - end - - if customSet then - customSet(optKey) - else - dbTable[dbKey] = optKey - end - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, customGet and customGet() or optKey) - end - - UpdateText() - menuFrame:Hide() - DF:UpdateAll() - if callback then callback() end - if parent.RefreshStates then parent:RefreshStates() end - end) - - menuButtons[i] = menuBtn - end - - menuBtn.optKey = opt.key - menuBtn.Text:SetText(opt.value) - menuBtn.Highlight:SetColorTexture(selColor.r, selColor.g, selColor.b, 0.3) - if opt.header then - -- Non-clickable group label (no mouse ⇒ no hover highlight). - -- Heading treatment: small uppercase label in the group colour, - -- separator line above (except when it's the first row), and - -- the option rows beneath it are indented — so headers read as - -- section labels rather than selectable entries. - menuBtn:EnableMouse(false) - local hc = opt.color or C_TEXT_DIM - menuBtn.Text:SetTextColor(hc.r, hc.g, hc.b) - -- SetTextScale (not SetSettingsFont) so the pooled row resets - -- cleanly when it's reused as a regular option row. - menuBtn.Text:SetTextScale(0.85) - if type(opt.value) == "string" then - menuBtn.Text:SetText(opt.value:upper()) - end - menuBtn.Text:ClearAllPoints() - menuBtn.Text:SetPoint("LEFT", 8, 0) - menuBtn.Sep:SetShown(i > 1) - seenHeader = true - else - menuBtn.Text:SetTextScale(1) - menuBtn.Text:ClearAllPoints() - menuBtn.Text:SetPoint("LEFT", seenHeader and 16 or 8, 0) - menuBtn.Sep:Hide() - menuBtn:EnableMouse(true) - if opt.color then - -- per-option colour (e.g. class-coloured spec list) always wins - menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) - elseif currentVal == opt.key then - menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - menuBtn:Show() - menuHeight = menuHeight + ITEM_HEIGHT - end - - -- Width fits the widest item so long options aren't clipped by a narrow - -- opener (refined to max(opener, content) on open, once btn is sized). - menuContentW = 0 - for i = 1, #sortedOptions do - menuContentW = math.max(menuContentW, menuButtons[i].Text:GetStringWidth() or 0) - end - if searchable then - local visible = math.min(#sortedOptions, MAX_VISIBLE) - menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) - menuFrame:SetHeight(visible * ITEM_HEIGHT + SEARCH_HEIGHT + 8) - scrollChild:SetWidth(menuFrame:GetWidth() - 24) - scrollChild:SetHeight(menuHeight + 4) - scrollFrame:SetVerticalScroll(0) - else - menuFrame:SetWidth(menuContentW + 24) - menuFrame:SetHeight(menuHeight + 4) - end - end - - BuildMenuButtons() - - if searchBox then - searchBox:SetScript("OnTextChanged", function(self) - if menuFrame:IsShown() then BuildMenuButtons(self:GetText()) end - end) - end - - -- Allow dynamic dropdowns to swap their option set and regenerate buttons. - container.RebuildOptions = function(_, newOptions) - if newOptions then options = newOptions end - BuildMenuButtons(searchBox and searchBox:GetText() or nil) - UpdateText() - end - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - btn:SetScript("OnClick", function(self) - if menuFrame:IsShown() then - menuFrame:Hide() - S.currentOpenDropdown = nil - else - -- Close any other open dropdown first - CloseOpenDropdown() - -- Dynamic dropdowns regenerate their option list each open. - if optionsFunc then - container:RebuildOptions(optionsFunc()) - elseif searchable then - -- Searchable menus reopen unfiltered (search cleared on hide) - BuildMenuButtons() - else - -- Static menus: refresh selected-value colouring on the pooled rows - local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) - local selColor = accentColor or GetThemeColor() - for i, opt in ipairs(sortedOptions) do - local menuBtn = menuButtons[i] - if menuBtn and not opt.header then - if opt.color then - -- per-option colour (e.g. class-coloured spec list) always wins - menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) - elseif currentVal == opt.key then - menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - end - end - if searchable then - menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) - scrollChild:SetWidth(menuFrame:GetWidth() - 24) - else - menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 24)) - end - menuFrame:Show() - S.currentOpenDropdown = menuFrame - if searchBox then searchBox:SetFocus() end - end - end) - - btn:SetScript("OnShow", UpdateText) - UpdateText() - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so its preview/value (texture swatch, font preview, - -- selected text) greys with the label rather than staying full-bright. - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - UpdateText() -- restore per-option colour on the selected label - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The label - -- sits at the container's TOPLEFT, above the opener, so it is well clear of - -- the menu you are about to click. - GUI:AttachTooltip(container, label, lbl) - - -- SEARCH: Register this setting - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, options, nil, callback) - end - - return container -end - --- ============================================================ --- OUTLINE + SHADOW CONTROLS --- A flag dropdown and a shadow checkbox that both bind to a single stored --- outline value (see DF:OutlineFlag / OutlineHasShadow / ComposeOutline in --- Config.lua). Shadow is decoupled from the outline flag so any flag can be --- combined with a drop shadow, mirroring Grid2's font options. --- ============================================================ - -local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "MONOCHROME, OUTLINE", "MONOCHROME, THICKOUTLINE" } - -function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) - local options = { - NONE = L["None"], - OUTLINE = L["Outline"], - THICKOUTLINE = L["Thick Outline"], - MONOCHROME = L["Monochrome"], - ["MONOCHROME, OUTLINE"] = L["Monochrome Outline"], - ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], - _order = OUTLINE_FLAG_ORDER, - } - local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end - local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end - return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) -end - -function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) - local get = function() return DF:OutlineHasShadow(dbTable[dbKey]) end - local set = function(val) dbTable[dbKey] = DF:ComposeOutline(DF:OutlineFlag(dbTable[dbKey]), val) end - return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) -end - --- ============================================================ --- UNIFIED BORDER CONTROL SET --- Drops the canonical Show / Style / Texture / Size / Colour controls plus --- whichever optional Phase B controls the consumer opts into (offset, inset, --- blendMode, gradient, shadow). Saved-variable keys are built from a single --- camelCase `prefix` (e.g. "defensiveIcon" → "defensiveIconBorderSize"), so --- consumers add one call instead of hand-rolling ~6-15 widgets each. --- --- Each opts.include flag is per-element: "tailor-made to what makes logical --- sense" — the API exposes everything, but consumers opt in only to what fits --- their element. Returns a table of widget references so the caller can add --- per-element extras (dispel-type colour, pulsate, etc.) afterwards. --- --- opts = { --- parent = the panel widget (e.g. self.child) — same first arg the --- underlying CreateCheckbox/Slider/etc. take --- include = { offset=, inset=, blendMode=, gradient=, shadow=, --- classColor=, roleColor=, colorByTime=, colorByType= } --- fullUpdate = callback for full re-render (drop / value-set) --- lightUpdate = callback for slider-drag (size, offsets, shadow sliders) --- lightColors = callback for live colour-picker preview --- refreshStates = optional hook fired when Show/Gradient/Shadow toggles --- change visibility of other widgets --- disableWhen = optional predicate fn(db) → bool. When true, EVERY widget --- (including the Show toggle itself) GREYS OUT in place. This --- is what a consumer whose border sits under a feature toggle --- wants — the addon-wide rule is that a deactivated control --- greys where it is, so the page doesn't reflow and you can --- still see what turning the feature on would give you. --- hideWhen = optional predicate fn(db) → bool. When true, EVERY widget --- (including the Show toggle itself) HIDES. Reserve this for a --- gate that changes WHAT the page offers — a variant switch --- like Pinned Frames' per-set border override, where the --- controls belong to a mode you are not in. A plain on/off --- feature toggle is disableWhen, not this. --- sizeMin / sizeMax / sizeStep = slider range overrides --- offsetMin / offsetMax / offsetStep --- } --- ============================================================ --- CreateAnimationControls — the Border Animation control set --- (Type dropdown + every per-effect tunable), extracted so the base --- Border Animation panel (CreateBorderControls / include.animate) AND --- Aura Designer's Expiring Animation override render an IDENTICAL set of --- widgets from ONE source. Add or remove an effect / tunable here and both --- panels update together — no drift. --- --- group = SettingsGroup the widgets are added to --- dbTable = db / proxy the widgets read & write --- animPrefix = key namespace; widgets target dbTable[animPrefix .. suffix] --- (base border: "BorderAnimation"; AD expiring: --- "ExpiringAnimation") --- opts: --- parent = frame parent for the widgets --- fullUpdate = heavy refresh callback (dropdown / slider-release / colour) --- lightUpdate = light refresh callback (slider-drag) --- lightColors = live colour-picker preview callback (needed for AD's --- proxy, whose sub-table colour writes skip __newindex) --- typeLabel = label for the Type dropdown --- hideExtra = optional predicate; when true the WHOLE block hides --- (the border panel folds the block under Show Border; --- the always-visible Expiring override omits it) --- onTypeChange = runs after the Type dropdown changes (re-layout / reflow) --- perfBanner = show the per-border FPS warning banner (default true) --- Returns the widget table (animationType, animationColor, … ) so the caller --- can merge the handles into its own control table. --- ============================================================ -function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) - opts = opts or {} - local parent = opts.parent - local fullUpdate = opts.fullUpdate or function() end - local lightUpdate = opts.lightUpdate - local lightColors = opts.lightColors - local typeLabel = opts.typeLabel or L["Border Animation"] - local excludeTypes = opts.excludeTypes -- optional set of animation-type keys to omit from the dropdown - local hideExtra = opts.hideExtra - local onTypeChange = opts.onTypeChange or function() end - local showPerfBanner = opts.perfBanner ~= false - - local function aKey(suffix) return animPrefix .. suffix end - local animTypeKey = aKey("Type") - local function animType() return dbTable[animTypeKey] or "NONE" end - local function extraOff() return (hideExtra and hideExtra()) or false end - local function animOff() return extraOff() or animType() == "NONE" end - - -- Sets of effect types each tunable applies to (truthiness on a - -- string-keyed set). Mirrors the per-effect parameter map — keep in - -- sync with StartAnimation's branches in Frames/Border.lua. - -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash - -- thickness, Inset = dash inset. - local hasFrequency = { DF_PULSATE=1, - DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } - local hasParticles = { DF_ORBIT=1, DF_PIXEL=1 } - -- CORNERS_ONLY is hidden from the type dropdown below, but keep its param - -- entries so an indicator that still carries a saved CORNERS_ONLY value shows - -- the right controls. - local hasThickness = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_PIXEL=1 } - -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which - -- modulates the border's own edges and has no separate animRect). - local hasPositioning = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } - -- Scale slider = sparkle size (DF Chase). - local hasScale = { DF_ORBIT=1 } - -- Length slider = bar length (DF Pixel's chasing bars). - local hasLength = { DF_PIXEL=1 } - local cornersOnly = { CORNERS_ONLY=1 } - local function hideUnless(set) - return function() - if animOff() then return true end - return not set[animType()] - end - end - - local w = {} - - -- All DF-owned border effects (no external glow library). The "DF " labels - -- are kept from when they sat alongside the retired LCG glows. - local animTypeOptions = { - NONE = L["None"], - DF_PULSATE = L["DF Pulsate"], - DF_ORBIT = L["DF Chase"], - DF_DASH = L["DF Dash"], - DF_FLASH = L["DF Flash"], - DF_PIXEL = L["DF Pixel"], - DF_PROC = L["DF Proc"], - BLINK = L["Blink"], - -- None first (the "off" option), then alphabetical by label. CORNERS_ONLY - -- is intentionally absent — it's kept in the engine (an existing saved - -- value still renders) but no longer offered as a pickable animation. - _order = { "NONE", "BLINK", "DF_ORBIT", - "DF_DASH", "DF_FLASH", "DF_PIXEL", "DF_PROC", "DF_PULSATE" }, - } - -- Optional caller filter: drop any excluded type from both the value map and - -- the display order (e.g. the Aura Designer border offers only the taint-safe, - -- overlay-recoverable animations — no LCG glows). - if excludeTypes then - for k in pairs(excludeTypes) do animTypeOptions[k] = nil end - local filteredOrder = {} - for _, k in ipairs(animTypeOptions._order) do - if not excludeTypes[k] then filteredOrder[#filteredOrder + 1] = k end - end - animTypeOptions._order = filteredOrder - end - w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, - animTypeOptions, - dbTable, animTypeKey, onTypeChange), 55) - -- Type dropdown respects only the extra gate (e.g. Show Border). With no - -- extra gate (Expiring override) it's always visible. - w.animationType.hideOn = hideExtra or function() return false end - - -- Perf warning: animations run an OnUpdate (or LCG internal animation) - -- per active border, which adds up in 20-30 player raids. - if showPerfBanner then - -- staticHeight ONLY where the host reflows widget WIDTHS on every layout - -- pass — i.e. the Aura Designer indicator card (its parent carries - -- dfAD_ReflowWidgets). There a self-sizing banner feeds a SetHeight -> - -- OnSizeChanged -> relayout -> SetWidth loop that drops FPS, so we predict - -- a fixed height instead. On normal settings pages the host lays out once, - -- so the banner MUST self-size to its wrapped text: a fixed height - -- overflows (text spills past the box) on narrow windows until a manual - -- drag forces a relayout. - local reflowingHost = parent and parent.dfAD_ReflowWidgets ~= nil - local perfBanner = GUI:CreateInfoBanner(parent, { - tone = "caution", - text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], - staticHeight = reflowingHost or nil, - minHeight = 56, - }) - w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) - w.animationPerfBanner.hideOn = animOff - end - - -- Animation colour applies to every effect except DF_PULSATE (which - -- modulates the border's own edge alpha — no separate colour). lightColors - -- is threaded through so AD's proxy gets live preview while dragging. - w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Color"], - dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) - w.animationColor.hideOn = function() - return animOff() or animType() == "DF_PULSATE" - end - - -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. - -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), - -- and the OnUpdate effects fall back to a sensible default period at 0. - w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], - 0, 4, 0.05, dbTable, aKey("Frequency"), - fullUpdate, lightUpdate, true), 55) - w.animationFrequency.hideOn = hideUnless(hasFrequency) - -- ⚠ This slider genuinely means different things per effect (see the comment - -- above), which is exactly why it needs saying out loud — nobody discovers - -- "0 = hold still" by dragging. - w.animationFrequency.tooltip = L["How fast the effect runs. On DF Dash this is how quickly the dashes march around the edge, and 0 holds them still. On the others it is the pulse rate, where 0 means the effect's own default speed."] - - w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], - 1, 16, 1, dbTable, aKey("Particles"), - fullUpdate, lightUpdate, true), 55) - w.animationParticles.hideOn = hideUnless(hasParticles) - w.animationParticles.tooltip = L["How many separate lights travel around the border. More reads as busier and costs a little more to draw."] - - w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], - 1, 30, 1, dbTable, aKey("Length"), - fullUpdate, lightUpdate, true), 55) - w.animationLength.hideOn = hideUnless(hasLength) - w.animationLength.tooltip = L["How long each moving segment is. Short values read as darting sparks, long ones as a sweeping tail."] - - w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], - 1, 12, 1, dbTable, aKey("Thickness"), - fullUpdate, lightUpdate, true), 55) - w.animationThickness.hideOn = hideUnless(hasThickness) - w.animationThickness.tooltip = L["How heavy the moving effect is. Separate from Border Thickness — the animation draws on its own layer, so it can be thicker or thinner than the border underneath."] - - w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], - 0.5, 3, 0.05, dbTable, aKey("Scale"), - fullUpdate, lightUpdate, true), 55) - w.animationScale.hideOn = hideUnless(hasScale) - - w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], - -50, 50, 1, dbTable, aKey("Inset"), - fullUpdate, lightUpdate, true), 55) - w.animationInset.hideOn = hideUnless(hasPositioning) - w.animationInset.tooltip = L["Moves the effect in or out from the edge, independently of the border. Push it outward to make a glow spill past the frame."] - - w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], - -50, 50, 1, dbTable, aKey("OffsetX"), - fullUpdate, lightUpdate, true), 55) - w.animationOffsetX.hideOn = hideUnless(hasPositioning) - - w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], - -50, 50, 1, dbTable, aKey("OffsetY"), - fullUpdate, lightUpdate, true), 55) - w.animationOffsetY.hideOn = hideUnless(hasPositioning) - - -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). - w.animationHideIntro = group:AddWidget(GUI:CreateCheckbox(parent, L["Hide Intro Flash"], - dbTable, aKey("ProcStart"), fullUpdate), 30) - w.animationHideIntro.hideOn = hideUnless({ DF_FLASH = 1, DF_PROC = 1 }) - w.animationHideIntro.tooltip = L["These effects open with a one-off burst before settling into their loop. Turn this on to skip the burst and go straight to the loop."] - - w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], - 2, 40, 1, dbTable, aKey("CornerLength"), - fullUpdate, lightUpdate, true), 55) - w.animationCornerLength.hideOn = hideUnless(cornersOnly) - w.animationCornerLength.tooltip = L["How far the effect runs along each edge from the corner before stopping. Small values leave four short brackets instead of a full outline."] - - return w -end - --- ============================================================ -function GUI:CreateBorderControls(group, dbTable, prefix, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local fullUpdate = opts.fullUpdate or function() end - local lightUpdate = opts.lightUpdate - local lightColors = opts.lightColors - local refreshStates = opts.refreshStates - local hideWhen = opts.hideWhen - local disableWhen = opts.disableWhen - - local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 - local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 - - local function key(suffix) return prefix .. suffix end - local showKey = key("ShowBorder") - -- The Show toggle only respects the parent-level hideWhen. Everything - -- else respects hideWhen OR the Show toggle being off. - -- - -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is - -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` - -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, - -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. - -- For consumers with a different dbTable — notably Aura Designer's - -- per-aura proxy — this is the only way the visibility predicates see - -- the actual border state (e.g. proxy.BorderStyle, not the unrelated - -- DF.db.party.BorderStyle which doesn't exist). - local function hideShow() return hideWhen and hideWhen(dbTable) or false end - -- Show Border OFF no longer HIDES the border controls — they stay visible and - -- GREY OUT (disableOn = borderOff, applied by the loop at the end of this - -- function) so the panel previews them. `hideOff` now means "hidden by the - -- parent/variant gate only" (whatever the consumer passes via hideWhen); the - -- name is kept so the existing `.hideOn = hideOff` references read unchanged. - local function hideOff() return hideShow() end - local function borderOff() return dbTable[showKey] == false end - - local w = {} - - -- opts.noShowToggle: suppress the built-in "Show Border" checkbox for - -- consumers that gate the whole border on an external toggle (e.g. the - -- Targeted Spells "Highlight Important Spells" master). With the checkbox - -- gone, showKey stays nil so hideOff() reduces to hideShow() — the toolkit - -- shows/hides purely on the external hideWhen. - if not opts.noShowToggle then - w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 30) - w.show.hideOn = hideShow - end - - -- Slider label reads "Border Thickness" (more meaningful than "Size") but - -- the underlying db key stays `BorderSize` and spec.size in the - -- backend stays the same — purely a user-facing rename, no migration. - w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, - dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) - w.size.hideOn = hideOff - - -- Gradient is a STYLE, not a separate toggle. When the consumer opts into - -- gradient via include.gradient, we expose GRADIENT as a third dropdown - -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. - local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], - _order = { "SOLID", "TEXTURE" } } - if include.gradient then - styleOptions.GRADIENT = L["Gradient"] - -- Insert GRADIENT between SOLID and TEXTURE so the order reads - -- "simple colour → two colours → custom texture" in the dropdown. - styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } - end - w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], - styleOptions, dbTable, key("BorderStyle"), function() - -- Match the frame border: pick the first LSM border when switching - -- to Texture without one configured. - if dbTable[key("BorderStyle")] == "TEXTURE" then - local list = DF.GetBorderList and DF:GetBorderList() or nil - local t = dbTable[key("BorderTexture")] - if list and (not t or t == "" or t == "SOLID") then - dbTable[key("BorderTexture")] = next(list) - end - end - if refreshStates then refreshStates() end - fullUpdate() - end), 55) - w.style.hideOn = hideOff - - -- isGradient is declared up here so the Style-dependent widget cluster - -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) - -- can sit immediately below the Style dropdown — the consequence of the - -- user's style choice reads top-to-bottom without scrolling past - -- unrelated inset / offset / blend controls first. - local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end - - w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], - DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) - w.texture.hideOn = function() - return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" - end - - -- Gradient pickers — only visible under Style = GRADIENT. Grouped here - -- (between Texture and the Colour Source dropdown) so all style-dependent - -- widgets sit directly under the Style dropdown that controls them. - -- The standalone "Border Gradient" checkbox was removed when Style - -- absorbed it; Style is now the single source of truth so it's not - -- possible to pick "Solid + Class Color" then have a Gradient checkbox - -- stomp the class colour (the previous UX bug). Legacy - -- `BorderGradientEnabled = true` profiles are migrated to - -- `BorderStyle = "GRADIENT"` on db load. - if include.gradient then - local function gradHide() return hideOff() or not isGradient() end - - w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Color"], - dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) - w.gradientStart.hideOn = gradHide - w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Color"], - dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) - w.gradientEnd.hideOn = gradHide - w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], - { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, - dbTable, key("BorderGradientDirection"), fullUpdate), 55) - w.gradientDirection.hideOn = gradHide - end - - -- Colour Source dropdown sits ABOVE the colour picker so the relationship - -- "source → resulting colour" reads top-to-bottom in the panel. The - -- options table is built dynamically: Static is always present; Class - -- and Role are added if the consumer opted in via the matching include. - -- Hidden in GRADIENT style — gradient owns its own colours, no resolver - -- chain applies (see Border:BuildSpec). - local sourceKey = key("BorderColorSource") - local hasSourceDropdown = include.classColor or include.roleColor - if hasSourceDropdown then - local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } - if include.classColor then - sourceOptions.CLASS = L["Class"] - sourceOptions._order[#sourceOptions._order + 1] = "CLASS" - end - if include.roleColor then - sourceOptions.ROLE = L["Role"] - sourceOptions._order[#sourceOptions._order + 1] = "ROLE" - end - -- Default the source from the legacy boolean keys when first opened. - if dbTable[sourceKey] == nil then - if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" - elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" - else dbTable[sourceKey] = "STATIC" end - end - w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], - sourceOptions, dbTable, sourceKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 55) - w.colorSource.hideOn = function() return hideOff() or isGradient() end - w.colorSource.tooltip = L["Where the border colour comes from. Static uses the colour below; Class and Role read it from the unit, so the border tells you who you are looking at without reading the name."] - end - - -- Static colour picker — only visible when source is STATIC (or when the - -- consumer didn't enable any resolver at all, so source doesn't exist). - -- Hidden in GRADIENT style (gradient uses its own start/end pickers). - w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), - true, fullUpdate, lightColors, lightColors ~= nil), 35) - w.color.hideOn = function() - if hideOff() or isGradient() then return true end - if hasSourceDropdown then - local src = dbTable[sourceKey] or "STATIC" - return src ~= "STATIC" - end - return false - end - - -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes - -- the SAME alpha component the colour picker exposes - -- (BorderColor.a), so the slider is just a convenient handle for - -- the picker's alpha bar — no separate alpha key to migrate or keep in - -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the - -- two gradient pickers each carry their own alpha, and a single slider - -- has no obvious meaning). - if include.alpha then - -- Ensure the underlying colour table has an alpha component so the - -- slider doesn't read nil on first open. The picker also seeds .a but - -- we don't depend on widget-creation order. - local c = dbTable[key("BorderColor")] - if type(c) ~= "table" then - c = { r = 0, g = 0, b = 0, a = 1 } - dbTable[key("BorderColor")] = c - end - if c.a == nil then c.a = 1 end - - -- Read-time nil-guard: these closures fire on the slider's OnShow at - -- arbitrary later times (tab/page re-show, mode switch), NOT just at - -- creation. The seed above only guarantees the table exists NOW — a proxy - -- dbTable can resolve BorderColor to nil later (e.g. re-showing the AD page - -- for a mode whose config doesn't surface the key), so re-read and guard - -- each call instead of assuming the table is still there. - w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, - nil, nil, fullUpdate, lightColors or lightUpdate, true, - function() - local bc = dbTable[key("BorderColor")] - return (bc and bc.a) or 1 - end, - function(v) - local bc = dbTable[key("BorderColor")] - if bc then bc.a = v end - end), 55) - w.alpha.hideOn = function() return hideOff() or isGradient() end - end - - if include.inset then - w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, - dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) - w.inset.hideOn = hideOff - -- Thickness / Inset / Offset are three similar-sounding sliders that do - -- different things; the tooltip lives here because Inset is the one - -- nobody guesses. - w.inset.tooltip = L["Pulls the border inward (positive) or pushes it outward (negative) from the edge. Thickness is how heavy the line is, Inset is how far in it sits, Offset slides the whole border sideways."] - end - - if include.offset then - w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, - dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) - w.offsetX.hideOn = hideOff - w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, - dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) - w.offsetY.hideOn = hideOff - -- No tooltip on Offset X/Y, deliberately, and the same goes for every - -- other Offset slider in the addon (~60 of them): an offset is a well - -- understood control and a tooltip restating it is noise. Inset is the - -- one that needs explaining, so the Thickness / Inset / Offset - -- distinction is spelled out THERE, once. Krathe's call, 2026-07-27 — - -- these two briefly had tooltips and Border Shadow's offsets did not, - -- which is the inconsistency that prompted it. - end - - if include.blendMode then - w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], - { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"], DISABLE = L["Disable"] }, - dbTable, key("BorderBlendMode"), fullUpdate), 55) - w.blendMode.hideOn = hideOff - w.blendMode.tooltip = L["How the border colour mixes with whatever is behind it. Blend is normal. Add brightens and is what makes a colour glow. Modulate darkens. Disable ignores opacity entirely and draws the colour flat."] - end - - if include.shadow then - local shadowOnKey = key("BorderShadowEnabled") - w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 30) - w.shadowEnabled.hideOn = hideOff - -- Border Shadow OFF greys (not hides) its sub-controls — a nested boolean - -- toggle, same grey-everything rule. The end-of-function loop OR-composes - -- borderOff, so these also grey when Show Border is off. - local function shadowOff() return dbTable[shadowOnKey] == false end - - w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Color"], - dbTable, key("BorderShadowColor"), true, fullUpdate), 35) - w.shadowColor.hideOn = hideOff - w.shadowColor.disableOn = shadowOff - w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, - dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) - w.shadowSize.hideOn = hideOff - w.shadowSize.disableOn = shadowOff - w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, - dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) - w.shadowOffsetX.hideOn = hideOff - w.shadowOffsetX.disableOn = shadowOff - w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, - dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) - w.shadowOffsetY.hideOn = hideOff - w.shadowOffsetY.disableOn = shadowOff - end - - -- ===== Animation (Stage 3) ===== - -- include.animate drops the full Border Animation control set (Type - -- dropdown + per-effect tunables, each with a hideOn keyed to the effect - -- it applies to). Built from the shared GUI:CreateAnimationControls so the - -- base panel and AD's Expiring override never drift. The whole block folds - -- under Show Border via hideExtra = hideOff. Widget handles are merged back - -- onto `w` so existing references (w.animationType, …) are preserved. - if include.animate then - local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { - parent = parent, - fullUpdate = fullUpdate, - lightUpdate = lightUpdate, - lightColors = lightColors, - typeLabel = L["Border Animation"], - -- Optional caller filter, forwarded from the CreateBorderControls call - -- site (e.g. the Aura Designer border restricts to overlay-recoverable - -- animation types). nil for every other caller → full type list. - excludeTypes = opts.animExcludeTypes, - hideExtra = hideOff, - onTypeChange = function() - if refreshStates then refreshStates() end - fullUpdate() - end, - }) - for k, v in pairs(aw) do w[k] = v end - end - - -- ===== Colour resolver toggles (Stage 2) ===== - -- These flip BorderColor's source from the static picker to a per-unit / - -- per-aura / per-tick computation. BuildSpec applies them in priority - -- order (type > time > class > role > static) when the consumer passes - -- ctx to BuildSpec. The static colour picker still controls the fallback - -- (when ctx is missing or the resolver yields nil). - - -- (Colour Source dropdown + Static colour picker + Alpha slider are wired - -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the - -- relationship "source → colour" reads top-to-bottom in the panel.) - - if include.colorByTime then - w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) - w.colorByTime.hideOn = hideOff - -- The actual colour curve picker is consumer-specific (e.g. AD's - -- existing expiring colour curve) and is added by the consumer - -- alongside this checkbox. - end - - if include.colorByType then - w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) - w.colorByType.hideOn = hideOff - end - - -- Two independent greys, both composed on top of whatever disableOn a control - -- already carries (e.g. the shadow sub-controls), and both leaving the - -- variant hideOn untouched: - -- disableWhen — the CONSUMER's gate: the feature this border belongs to is - -- switched off. Applies to EVERY widget including the Show Border - -- checkbox, since with the feature off there is nothing for it to show. - -- borderOff — Show Border itself is off. Applies to everything EXCEPT the - -- Show Border checkbox, which has to stay clickable to turn it back on. - -- RefreshChildStates applies disableOn to group children, and CreateCheckbox - -- auto-refreshes on toggle, so both greys update live. - for k, widget in pairs(w) do - if type(widget) == "table" and widget.SetEnabled then - local prev = widget.disableOn - local isShow = (k == "show") - widget.disableOn = function(d) - if disableWhen and disableWhen(dbTable) then return true end - if not isShow and borderOff() then return true end - return (prev and prev(d)) or false - end - end - end - - return w -end - --- ============================================================ --- SHARED TEXT-STYLE CONTROLS (pairs with DF.TextStyle — the engine consumers --- style FontStrings through). Mirrors CreateBorderControls: one builder, every --- text block in the addon renders the same control flow instead of a hand-rolled --- copy per page. Emits, in the pages' established order: --- Font, Scale, Outline, Shadow, [Color], Anchor, Offset X/Y, [Justify H, Justify V] --- Key convention: Font/Scale/Outline/Anchor/X/Y/JustifyH/JustifyV/Color. --- --- opts: --- parent REQUIRED — the page scroll child (self.child) --- include = { color = false, justify = true, anchor = true, offsets = true } --- colorLabel colour picker label (default L["Text Color"]) --- disableOn predicate applied to EVERY created widget (page-level gate) --- hideOn predicate applied to EVERY created widget --- colorDisableOn EXTRA disable gate for the colour picker only (e.g. colour-by-time on) --- onChange full-update callback (dropdowns / colour commit) --- onDrag lightweight slider-drag callback (also colour live-preview) --- scaleMin/Max/Step, offsetMin/Max — slider ranges (defaults 0.5–2.0 ×0.05, ±150) --- Returns the created widgets keyed { font, scale, outline, shadow, color, anchor, --- offsetX, offsetY, justifyH, justifyV } so pages can attach extra gates. --- ============================================================ -function GUI:CreateTextControls(group, dbTable, prefix, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local onChange = opts.onChange - local onDrag = opts.onDrag or onChange - local L = DF.L - - local scaleMin, scaleMax, scaleStep = opts.scaleMin or 0.5, opts.scaleMax or 2.0, opts.scaleStep or 0.05 - local offMin, offMax = opts.offsetMin or -150, opts.offsetMax or 150 - - local function key(suffix) return prefix .. suffix end - local widgets = {} - - -- Apply the shared page gates to a widget, composing with any the widget factory set. - local function gate(w) - if opts.disableOn then - local prev = w.disableOn - w.disableOn = function(d) return (opts.disableOn(d) or (prev and prev(d))) and true or false end - end - if opts.hideOn then w.hideOn = opts.hideOn end - return w - end - - widgets.font = gate(group:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], dbTable, key("Font"), onChange), 55)) - widgets.scale = gate(group:AddWidget(GUI:CreateSlider(parent, L["Scale"], scaleMin, scaleMax, scaleStep, dbTable, key("Scale"), nil, onDrag, true), 55)) - widgets.outline = gate(group:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], dbTable, key("Outline"), onChange), 55)) - widgets.shadow = gate(group:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], dbTable, key("Outline"), onChange), 30)) - - if include.color then - widgets.color = gate(group:AddWidget(GUI:CreateColorPicker(parent, opts.colorLabel or L["Text Color"], dbTable, key("Color"), false, onChange, onDrag, true), 35)) - if opts.colorDisableOn then - local prev = widgets.color.disableOn - widgets.color.disableOn = function(d) return (opts.colorDisableOn(d) or (prev and prev(d))) and true or false end - end - end - - if include.anchor ~= false then - local anchorOptions = { - CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], - TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], - } - widgets.anchor = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], anchorOptions, dbTable, key("Anchor"), onChange), 55)) - -- Anchor vs Justify is the pair people get wrong: one places the text, - -- the other arranges it within its own box. Both say so, from their side. - widgets.anchor.tooltip = L["Which part of the element the text is pinned to. Offset X and Y then nudge it from there."] - end - - if include.offsets ~= false then - widgets.offsetX = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], offMin, offMax, 1, dbTable, key("X"), nil, onDrag, true), 55)) - widgets.offsetY = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], offMin, offMax, 1, dbTable, key("Y"), nil, onDrag, true), 55)) - end - - -- Justify is OPT-IN (include.justify = true). It's redundant with Anchor for short - -- single-token text on a small icon (duration/stacks) and boxing to justify TRUNCATES - -- wide text like "59m" — so the aura pages don't expose it. The DF.TextStyle engine - -- still honors JustifyH/JustifyV keys for a future wide/fixed-region consumer. - if include.justify then - local justifyHOptions = { [""] = L["Default"], LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } - local justifyVOptions = { [""] = L["Default"], TOP = L["Top"], MIDDLE = L["Middle"], BOTTOM = L["Bottom"] } - widgets.justifyH = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify H"], justifyHOptions, dbTable, key("JustifyH"), onChange), 55)) - widgets.justifyH.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] - widgets.justifyV = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify V"], justifyVOptions, dbTable, key("JustifyV"), onChange), 55)) - widgets.justifyV.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] - end - - return widgets -end - --- ============================================================ --- EXPIRATION CONTROLS (shared) — the 12.1-safe Expiration panel. Pairs with the --- DF.Expiration engine (Features/Expiration.lua): the engine turns the expiryAlert* keys --- into a secret-safe reveal, this builds the UI for them, so every consumer (AD icon/square --- now; frame-level indicators later) renders the same flow with no hand-rolled copy. --- --- Flow: a master Enable toggle, then Threshold, then a Type dropdown (Border / Tint / Text / --- Glyph — no Off; Enable owns on/off), then the Type-specific controls. --- --- HIDE-vs-GREY policy (the rework rule): a control that does NOT belong to the current Type is --- HIDDEN (its row collapses via hideOn + the group's LayoutChildren + the caller's reflow); a --- control that belongs but is momentarily inactive is GREYED via disableOn / the group's --- disableChildrenOn (the standard grey-out). So: --- Enable off -> everything below the toggle GREYS (stays visible to preview), like --- CreateBorderControls' "Show Border off => grey". --- TEXT/GLYPH -> the text box / glyph dropdown + Anchor (plus Threshold/Offsets/Size). --- BORDER -> Match, Colour Mode, Colour (GREY under By-Time), Style, Inset, Opacity --- (plus Threshold/Offsets/Size — Size GREYS under Match). No Anchor (centres). --- TINT -> as BORDER but no Style (a wash has no thickness). --- --- Keys are the fixed expiryAlert* set on the passed dbTable (the AD per-aura proxy, or any --- consumer's table) — every consumer stores the same keys, so no key map is needed. hideOn/ --- disableOn predicates read dbTable directly (ignoring the arg LayoutChildren/RefreshChildStates --- pass, which is DF.db[SelectedMode]) — the CreateBorderControls convention, and the only way --- a per-aura proxy's state is seen. --- --- opts: --- parent REQUIRED — the card/page scroll child (widgets parent to it). --- fullUpdate value-change callback (re-render preview + live frames). --- refreshStates relayout callback — MUST re-run hideOn (LayoutChildren), disableOn --- (RefreshChildStates) and the sibling reflow so a mode change collapses the --- now-irrelevant rows and slides neighbours. The mode / match / colour-mode --- controls fire it. Called once by the caller after build for the initial state. --- include { text, glyph, border, tint } — default all true; a consumer can drop modes. --- anchorOptions the Anchor dropdown's option table (default: the standard 9-anchor set). --- Returns the widget table keyed by role so a consumer can attach extra gates. --- ============================================================ -function GUI:CreateExpirationControls(group, dbTable, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local fullUpdate = opts.fullUpdate or function() end - local refreshStates = opts.refreshStates or function() end - local L = DF.L - - -- include.match (default true): square consumers (icon/square) offer Match Icon Size + a - -- manual Size for frame modes. A RECTANGULAR consumer (bar/health) passes match=false — its - -- Tint always fills the target, so there's no Match toggle and no manual Size for it. - local includeMatch = include.match ~= false - local function enabled() return dbTable.expiryAlertEnabled and true or false end - local function mode() return dbTable.expiryAlertMode or "BORDER" end - local function isFrame() local m = mode(); return m == "BORDER" or m == "TINT" end - -- A Type / Match / Colour-Mode change alters which rows show and which grey, so it must - -- relayout + reflow AND re-render. (Value-only edits ride fullUpdate alone.) - local function onStructural() refreshStates(); fullUpdate() end - - local w = {} - - -- Master enable. When off, the whole section GREYS (group.disableChildrenOn below) — the - -- controls stay visible so the panel still previews them, matching CreateBorderControls' - -- "Show Border off => grey, don't hide". keepEnabled keeps this toggle itself clickable. - w.enable = group:AddWidget(GUI:CreateCheckbox(parent, L["Enable"], dbTable, - "expiryAlertEnabled", onStructural), 28) - w.enable.keepEnabled = true - - -- Threshold + its UNIT (right under Enable): the "show when remaining time drops below - -- N" gate every type shares. The unit is PER INDICATOR — a glyph revealing at 5 seconds - -- and a border revealing at 30% are both legitimate — and it also selects which shared - -- Colours-page ramp a by-time Border/Tint reads, because the threshold and the bands - -- are ONE formatter sampled against ONE duration property (see Features/Auras.lua). - -- Percent tops out at 100; seconds keep the original 60s ceiling. - -- ONE STORED VALUE PER UNIT (mirrors the ramps, and DF.Expiration:Threshold reads the - -- same pair): a threshold cannot be reinterpreted between units, so each keeps its own - -- and switching back finds it untouched. - -- The shared threshold row (AD's design, six other cards already use it): the slider - -- with a compact unit button sitting directly above its value box, so the number and - -- the unit read as one control. unitKeys gives it the per-unit key pair, so toggling - -- swaps which value is live rather than reinterpreting one. - -- Structural: the formatter is bind-frozen, so a unit change must Rebuild the companion - -- (DF.Expiration:StructSig folds the unit in) — refreshPage carries onStructural, and - -- the row also re-captions and re-ranges itself in place. - w.threshold = group:AddWidget(GUI:CreateExpiringThresholdRow(parent, dbTable, { - thresholdModeKey = "expiryAlertThresholdUnit", - unitKeys = { SECONDS = "expiryAlertThreshold", PERCENT = "expiryAlertThresholdPercent" }, - labels = { SECONDS = L["Alert Below (seconds)"], PERCENT = L["Alert Below (%)"] }, - ranges = { SECONDS = { min = 1, max = 60, step = 1 }, - PERCENT = { min = 1, max = 100, step = 1 } }, - -- Seeded on first use: an unset percent threshold would read 1 and hide the - -- reveal in the final 1% of the aura. - defaults = { SECONDS = 5, - PERCENT = (DF.Expiration and DF.Expiration.PERCENT_THRESHOLD_DEFAULT) or 30 }, - refreshPage = onStructural, - }), 54) - - -- Type — the reveal kind (no Off; the Enable toggle owns on/off). Border / Tint lead (the - -- primary reveals), then the Text / Glyph payloads. Consumers can drop types via include. - local modeOptions = { _order = {} } - local function addMode(key, label, on) - if on == false then return end - modeOptions[key] = label - modeOptions._order[#modeOptions._order + 1] = key - end - addMode("BORDER", L["Border"], include.border) - addMode("TINT", L["Tint"], include.tint) - addMode("TEXT", L["Custom Text"], include.text) - addMode("GLYPH", L["Glyph"], include.glyph) - w.mode = group:AddWidget(GUI:CreateDropdown(parent, L["Type"], modeOptions, - dbTable, "expiryAlertMode", onStructural), 54) - - -- TEXT: the custom alert string. - w.text = group:AddWidget(GUI:CreateEditBox(parent, L["Alert Text"], dbTable, "expiryAlertText"), 48) - w.text.hideOn = function() return mode() ~= "TEXT" end - - -- GLYPH: the glyph dropdown. Labels embed the atlas escape as a live preview via the - -- shared escape builder, so the dropdown can never drift from the live band string. - local glyphOptions = { _order = {} } - for i, gl in ipairs(DF.ExpiryAlertGlyphs) do - glyphOptions[gl.key] = DF:GetExpiryAlertGlyphEscape(gl.key, 16) .. " " .. L[gl.name] - glyphOptions._order[i] = gl.key - end - w.glyph = group:AddWidget(GUI:CreateDropdown(parent, L["Glyph"], glyphOptions, dbTable, "expiryAlertGlyph"), 54) - w.glyph.hideOn = function() return mode() ~= "GLYPH" end - - -- ── BORDER / TINT appearance: a secret-safe |T overlay revealed below the threshold, - -- tinted statically OR stepped through the same Colours-page breakpoints the duration text - -- uses. Colour Mode, Colour, Style, Opacity — every row here hides outside the frame modes. - local function hideNonFrame() return not isFrame() end - - w.colorMode = group:AddWidget(GUI:CreateDropdown(parent, L["Color Mode"], - { STATIC = L["Static"], BYTIME = L["Color by Time Remaining"] }, - dbTable, "expiryAlertBorderColorMode", onStructural), 54) -- By-Time greys the picker - w.colorMode.hideOn = hideNonFrame - - -- Cross-link to the shared Colours-page editor those By-Time breakpoints live in. Frame - -- modes only (like Color Mode itself) — a rectangular consumer with no Border/Tint (bar) - -- never reaches here, so its fixed ramp gets no link. Fixed-layout note, so size it up front. - local expLinkW = math.max(40, (group:GetWidth() or 260) - 2 * (group.padding or 10)) - w.colorsLink = GUI:CreateColorsPageLink(parent, expLinkW) - group:AddWidget(w.colorsLink, (w.colorsLink.layoutHeight or 16) + 2) - w.colorsLink.hideOn = hideNonFrame - - -- Say the blend limitation WHERE the by-time mode is chosen, not only on the - -- Colours page: the reveal's |T escapes ignore the vertex colour a curve writes, - -- so it steps even while duration text blends. - w.stepNote = group:AddWidget(GUI:CreateNote(parent, - L["The expiry border and tint always step between colors."], { width = expLinkW })) - w.stepNote.hideOn = function() - return not isFrame() or dbTable.expiryAlertBorderColorMode ~= "BYTIME" - end - - w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, - "expiryAlertBorderColor", false, fullUpdate, fullUpdate, true), 28) - w.color.hideOn = hideNonFrame - -- By-Time follows the Colours page, so the static picker is inert then — GREY (not hide) - -- so it reads as "switch to Static to use this". - w.color.disableOn = function() return dbTable.expiryAlertBorderColorMode == "BYTIME" end - - -- Style = the frame outline art (Thin/Medium/Thick — a scaled bitmap can't vary its own - -- line weight, hence discrete arts). BORDER only; a Tint is a solid wash with no thickness. - w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Style"], - { THIN = L["Thin"], MEDIUM = L["Medium"], THICK = L["Thick"], _order = { "THIN", "MEDIUM", "THICK" } }, - dbTable, "expiryAlertBorderThickness"), 54) - w.style.hideOn = function() return mode() ~= "BORDER" end - - -- Opacity: region alpha on the |T overlay (0 = invisible, 1 = full). Multiplies the art's - -- own alpha, so a Tint (50% art) tops out at a 50% wash while a frame can be fully opaque. - -- Grouped with the other appearance controls, NOT the placement run further down. - w.opacity = group:AddWidget(GUI:CreateSlider(parent, L["Opacity"], 0, 1, 0.05, dbTable, "expiryAlertBorderAlpha"), 54) - w.opacity.hideOn = hideNonFrame - - -- ── Size: Match Icon Size (auto) sits directly above Size (manual). Match is the auto/manual - -- switch and Size greys under it, so their adjacency shows the relationship. A rectangular - -- consumer (include.match = false) has no Match — its Tint always fills the target. - if includeMatch then - w.match = group:AddWidget(GUI:CreateCheckbox(parent, L["Match Icon Size"], dbTable, - "expiryAlertBorderMatchIcon", onStructural), 28) -- BORDER/TINT only - w.match.hideOn = hideNonFrame - end - - -- Size: TEXT/GLYPH use it as the font/glyph size. For a frame/tint it's the manual square - -- size — HIDDEN for a rectangular consumer (the tint auto-fills), and GREYED for a square - -- one while Match is on (auto-sized). - w.size = group:AddWidget(GUI:CreateSlider(parent, L["Size"], 6, 48, 1, dbTable, "expiryAlertSize"), 54) - w.size.hideOn = function() return not includeMatch and isFrame() end - w.size.disableOn = function() return includeMatch and isFrame() and dbTable.expiryAlertBorderMatchIcon ~= false end - - -- ── Placement: Inset (a frame/tint's fit off the icon edge), Anchor (Text/Glyph), Offsets. - w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Inset"], -10, 10, 1, dbTable, "expiryAlertBorderInset"), 54) - w.inset.hideOn = hideNonFrame - w.inset.tooltip = L["How far inside the icon edge the reveal sits. Negative values push it outward, so it rings the icon rather than sitting on it."] - - -- Anchor: Text/Glyph only — a frame/tint always centres (the engine forces CENTER), so - -- hide it in those modes rather than let a stale anchor de-centre the overlay. - w.anchor = group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], - opts.anchorOptions or { - CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], - TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], - _order = { "TOPLEFT", "TOP", "TOPRIGHT", "LEFT", "CENTER", "RIGHT", "BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT" }, - }, dbTable, "expiryAlertAnchor"), 54) - w.anchor.hideOn = function() local m = mode(); return m ~= "TEXT" and m ~= "GLYPH" end - - -- 0.5 step: the reveal rides the text engine (sub-pixel positioning we can't snap), so - -- half-steps let the user split a stubborn half-pixel offset integer steps jump over. - w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 0.5, dbTable, "expiryAlertOffsetX"), 54) - w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 0.5, dbTable, "expiryAlertOffsetY"), 54) - - -- Master gate: Enable off greys every control below the toggle (keepEnabled spares it). - -- Composes with each control's own disableOn (By-Time colour, Match-Icon size). - group.disableChildrenOn = function() return not enabled() end - - return w -end - --- Small dim inline subheader (section divider inside a SettingsGroup), matching --- AD's "State Overrides" / "Icon Effects" dividers. -function GUI:CreateExpiringSubheader(parent, text) - local frame = CreateFrame("Frame", nil, parent) - frame:SetHeight(18) - local label = frame:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(label, 8, "") - label:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 1) - label:SetText(text) - local c = GetThemeColor() - label:SetTextColor(c.r, c.g, c.b, 0.75) - return frame -end - --- Threshold slider + a compact s / % SEGMENT TOGGLE sitting directly above the --- slider's value box, so the number and the unit read as one control. The slider's --- label/range switch with the mode, so the row rebuilds the page on toggle via --- opts.refreshPage. Keys are parameterised (thresholdKey / thresholdModeKey) so any --- consumer's DB schema works. --- --- opts.unitKeys = { SECONDS = key, PERCENT = key } switches the row to ONE STORED --- VALUE PER UNIT instead of a single key reinterpreted between them. A threshold --- cannot be reinterpreted (5 seconds is not 5 percent), so with this set the toggle --- swaps which value is live and leaves the other untouched — no clamping, no reset. --- The slider then binds through customGet/customSet and re-labels/re-ranges itself --- from refreshContent, so the row is correct even if a consumer's refresh does not --- rebuild it. opts.labels / opts.ranges override the slider caption and range per --- unit; opts.modeText overrides the segment labels (default s / %); opts.resetValues --- the single-key reset pair. Every default preserves the original behaviour. -function GUI:CreateExpiringThresholdRow(parent, dbTable, opts) - opts = opts or {} - local tKey = opts.thresholdKey - local mKey = opts.thresholdModeKey - local unitKeys = opts.unitKeys - local refresh = opts.refreshPage or function() end - local width = opts.width or 248 - local labels = opts.labels or {} - local ranges = opts.ranges or {} - local modeText = opts.modeText or {} - local function secondsNow() return mKey and dbTable[mKey] == "SECONDS" or false end - local isSeconds = secondsNow() - - local container = CreateFrame("Frame", nil, parent) - container:SetHeight(54) - container:SetWidth(width) - - -- Caption + range for a unit. Ranges default to the original pair (seconds - -- 1-60 step 1; percent 5-100 step 5). - local function unitSpec(sec) - local r = ranges[sec and "SECONDS" or "PERCENT"] - if sec then - return labels.SECONDS or L["Expiring Threshold (seconds)"], - (r and r.min) or 1, (r and r.max) or 60, (r and r.step) or 1 - end - return labels.PERCENT or L["Expiring Threshold (%)"], - (r and r.min) or 5, (r and r.max) or 100, (r and r.step) or 5 - end - - local label, minV, maxV, step = unitSpec(isSeconds) - local slider - if unitKeys then - -- Per-unit keys: resolve on EVERY access so a toggle can never write one - -- unit's number into the other's key, and seed a unit's value on first use. - local function keyNow() return secondsNow() and unitKeys.SECONDS or unitKeys.PERCENT end - local function readValue() - local k = keyNow() - local v = tonumber(dbTable and dbTable[k]) - if v == nil then - local _, dMin = unitSpec(secondsNow()) - v = (opts.defaults and opts.defaults[secondsNow() and "SECONDS" or "PERCENT"]) or dMin - if dbTable then dbTable[k] = v end - end - return v - end - slider = GUI:CreateSlider(container, label, minV, maxV, step, - nil, nil, nil, nil, nil, - readValue, function(v) if dbTable then dbTable[keyNow()] = v end end) - slider.refreshContent = function(self) - local sec = secondsNow() - local lbl, lo, hi = unitSpec(sec) - if self.label then self.label:SetText(lbl) end - self:SetRange(lo, hi) -- also re-reads the value through customGet - end - else - -- Single key reinterpreted between units: clamp it into the new range. - if isSeconds then - if tKey and dbTable[tKey] and dbTable[tKey] > maxV then dbTable[tKey] = 10 end - else - if tKey and dbTable[tKey] and dbTable[tKey] < minV then dbTable[tKey] = 30 end - end - slider = GUI:CreateSlider(container, label, minV, maxV, step, dbTable, tKey) - end - slider:SetPoint("TOPLEFT", 0, 0) - slider:SetWidth(width) - - -- Unit picker: a two-segment toggle with the units ON the buttons, boxed in one - -- track, sitting directly above the slider's value box. Terse labels (s / %) keep - -- it to the button's footprint; each segment tooltips its full name. - local modeBtn = GUI:CreateSegmentToggle(container, { - { value = "SECONDS", label = modeText.SECONDS or L["s"], tooltip = L["Seconds"] }, - { value = "PERCENT", label = modeText.PERCENT or L["%"], tooltip = L["Percent"] }, - }, dbTable, mKey, function(newVal) - local toSeconds = (newVal == "SECONDS") - -- Reset the value ONLY when one key is being reinterpreted between units. - -- With unitKeys each unit keeps its own, so switching back finds it intact. - if not unitKeys and tKey then - local r = opts.resetValues or {} - dbTable[tKey] = toSeconds and (r.SECONDS or 10) or (r.PERCENT or 30) - end - refresh() - -- Re-sync in place as well as asking for a rebuild: a consumer whose refresh - -- only re-evaluates states would otherwise leave a stale caption and range. - if slider.refreshContent then slider:refreshContent() end - end, { - segmentWidth = opts.modeSegmentWidth or 26, - fallbackValue = "PERCENT", -- matches isSeconds: an unset mode key reads as percent - tooltipLines = { L["Threshold Mode"] }, - }) - modeBtn:SetPoint("BOTTOMRIGHT", slider, "TOPRIGHT", -10, 2) - - -- Composite row: forward grey-out (disableOn) to its slider + unit toggle so the - -- whole row dims when the expiring feature is off. The row dims uniformly via - -- SetAlpha; each child blocks its own interaction (the toggle's SetEnabled dims and - -- un-mouses its segments) — deliberately NOT SetDisabled or a raw Button:SetEnabled - -- on the segments, both of which fight the shared hover wash / SetActive state. - container.SetEnabled = function(_, enabled) - container:SetAlpha(enabled and 1 or 0.4) - if slider.SetEnabled then slider:SetEnabled(enabled) end - modeBtn:SetEnabled(enabled) - end - -- Keep the unit toggle in sync on external changes (profile switch, page refresh). - container.refreshContent = function() - modeBtn:Refresh() - if slider.refreshContent then slider:refreshContent() end - end - - return container -end - --- (GUI:CreateExpiringControls removed 2026-07-25 with the pre-12.1 Expiring system: --- it drove the remaining-time border/tint panel, which is unreadable on the 12.1 --- container path. The 12.1-safe panel is GUI:CreateExpirationControls above -- --- note the near-identical name; that one is current and engine-backed.) - --- ============================================================ --- GROWTH DIRECTION CONTROL --- Three linked dropdowns (Orientation, Wrap, Direction) that --- compose into a single growth value like "LEFT_UP" --- ============================================================ - --- Decompose "LEFT_UP" into {orientation, wrap, direction} -local function DecomposeGrowth(growth) - local primary, secondary = strsplit("_", growth or "LEFT_UP") - if not secondary then - -- Malformed value (no underscore) — fall back to LEFT_UP - return "HORIZONTAL", "UP", "LEFT" - end - if primary == "CENTER" then - if secondary == "UP" or secondary == "DOWN" then - return "HORIZONTAL", secondary, "CENTER" - else - return "VERTICAL", secondary, "CENTER" - end - elseif primary == "LEFT" or primary == "RIGHT" then - return "HORIZONTAL", secondary, primary - else - return "VERTICAL", secondary, primary - end -end - --- Compose {orientation, wrap, direction} back into "LEFT_UP" -local function ComposeGrowth(orientation, wrap, direction) - -- Safety: if wrap is nil, pick a sensible default for the orientation - if not wrap then - wrap = (orientation == "HORIZONTAL") and "UP" or "LEFT" - end - if direction == "CENTER" then - return "CENTER_" .. wrap - else - return direction .. "_" .. (wrap or "UP") - end -end - --- Map values when switching orientation so the selection stays sensible -local ORIENTATION_MAP = { - UP = "LEFT", DOWN = "RIGHT", LEFT = "UP", RIGHT = "DOWN", -} - -function GUI:CreateGrowthControl(parent, db, dbKey, callback) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 155) - - -- Read current decomposed state - local curOrientation, curWrap, curDirection = DecomposeGrowth(db[dbKey] or "LEFT_UP") - - -- Option tables per orientation - -- Display text is localized; the value-keys (HORIZONTAL, UP, …) and _order - -- arrays are raw identifiers and must NOT be localized. - local ORIENT_OPTIONS = { - HORIZONTAL = L["Horizontal"], - VERTICAL = L["Vertical"], - _order = {"HORIZONTAL", "VERTICAL"}, - } - local WRAP_OPTIONS = { - HORIZONTAL = { UP = L["Up"], DOWN = L["Down"], _order = {"UP", "DOWN"} }, - VERTICAL = { LEFT = L["Left"], RIGHT = L["Right"], _order = {"LEFT", "RIGHT"} }, - } - -- "From Center" (not "Center"): the row grows OUTWARD in both directions from the - -- anchor — a behaviour, not a direction like Left/Right — so the label reads true. - -- Stored value stays CENTER (a separate locale key from the generic L["Center"] - -- used by anchor pickers elsewhere). - local DIR_OPTIONS = { - HORIZONTAL = { LEFT = L["Left"], CENTER = L["From Center"], RIGHT = L["Right"], _order = {"LEFT", "CENTER", "RIGHT"} }, - VERTICAL = { UP = L["Up"], CENTER = L["From Center"], DOWN = L["Down"], _order = {"UP", "CENTER", "DOWN"} }, - } - - -- Shared write-back: recompose and save - local function WriteBack() - db[dbKey] = ComposeGrowth(curOrientation, curWrap, curDirection) - DF:UpdateAll() - if callback then callback() end - if parent.RefreshStates then parent:RefreshStates() end - end - - -- Sub-dropdown builder (simplified version of CreateDropdown, no override indicators) - local function BuildMiniDropdown(yOffset, label, options, getValue, setValue) - local frame = CreateFrame("Frame", nil, container) - frame:SetPoint("TOPLEFT", 0, yOffset) - frame:SetPoint("TOPRIGHT", 0, yOffset) - frame:SetHeight(50) - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local btn = CreateFrame("Button", nil, frame, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, -16) - btn:SetPoint("TOPRIGHT", 0, -16) - btn:SetHeight(24) - CreateElementBackdrop(btn) - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 8, 0) - btn.Text:SetPoint("RIGHT", -20, 0) - btn.Text:SetJustifyH("LEFT") - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local arrow = btn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -8, 0) - arrow:SetSize(12, 12) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") - menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) - menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) - menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menuFrame) - menuFrame:SetClampedToScreen(true) - CreateElementBackdrop(menuFrame) - menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) - menuFrame:Hide() - - menuFrame:SetScript("OnHide", function() - if S.currentOpenDropdown == menuFrame then - S.currentOpenDropdown = nil - end - end) - - local menuButtons = {} - - -- Rebuild populates menu items from current options - frame.Rebuild = function(self, newOptions) - for _, mb in ipairs(menuButtons) do mb:Hide() end - wipe(menuButtons) - - local sorted = {} - if newOptions._order then - for _, k in ipairs(newOptions._order) do - if newOptions[k] then - sorted[#sorted + 1] = { key = k, value = newOptions[k] } - end - end - else - for k, v in pairs(newOptions) do - if k ~= "_order" then - sorted[#sorted + 1] = { key = k, value = v } - end - end - table.sort(sorted, function(a, b) return a.value < b.value end) - end - - local menuHeight = 0 - for i, opt in ipairs(sorted) do - local menuBtn = CreateFrame("Button", nil, menuFrame) - menuBtn:SetPoint("TOPLEFT", 2, -2 - (i - 1) * 22) - menuBtn:SetPoint("TOPRIGHT", -2, -2 - (i - 1) * 22) - menuBtn:SetHeight(22) - - menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - menuBtn.Text:SetPoint("LEFT", 8, 0) - menuBtn.Text:SetText(opt.value) - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") - menuBtn.Highlight:SetAllPoints() - local c = GetThemeColor() - menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) - - menuBtn:SetScript("OnClick", function() - setValue(opt.key) - WriteBack() - btn.Text:SetText(opt.value) - menuFrame:Hide() - end) - - menuButtons[#menuButtons + 1] = menuBtn - menuHeight = menuHeight + 22 - end - menuFrame:SetHeight(menuHeight + 4) - - -- Update displayed text - local curVal = getValue() - btn.Text:SetText(newOptions[curVal] or tostring(curVal) or L["Select..."]) - end - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - btn:SetScript("OnClick", function(self) - if menuFrame:IsShown() then - menuFrame:Hide() - S.currentOpenDropdown = nil - else - CloseOpenDropdown() - -- Highlight current selection - local curVal = getValue() - local curDisplay = options[curVal] - for _, mb in ipairs(menuButtons) do - if mb.Text:GetText() == curDisplay then - mb.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) - else - mb.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - menuFrame:Show() - S.currentOpenDropdown = menuFrame - end - end) - - -- Expose btn for external enable/disable, and the label so the tooltip - -- attach at the bottom of this factory has a real region to sit on — lbl - -- is local to THIS builder, so reaching for it out there is a nil global. - frame.btn = btn - frame.Label = lbl - frame:Rebuild(options) - return frame - end - - -- Build the three dropdowns (forward-declare wrap/dir so orientation callback can reference them) - local wrapDD, dirDD - local orientDD = BuildMiniDropdown(0, L["Orientation"], ORIENT_OPTIONS, - function() return curOrientation end, - function(val) - if val ~= curOrientation then - -- Map wrap and direction to the new orientation - curWrap = ORIENTATION_MAP[curWrap] or curWrap - curDirection = (curDirection == "CENTER") and "CENTER" or (ORIENTATION_MAP[curDirection] or curDirection) - curOrientation = val - -- Rebuild dependent dropdowns with new options - wrapDD:Rebuild(WRAP_OPTIONS[curOrientation]) - dirDD:Rebuild(DIR_OPTIONS[curOrientation]) - end - end - ) - - wrapDD = BuildMiniDropdown(-50, L["Wrap"], WRAP_OPTIONS[curOrientation], - function() return curWrap end, - function(val) curWrap = val end - ) - - -- "Grow" (not "Direction"): the values describe how the row GROWS from the anchor - -- (toward a side, or outward from center) — clearer than "Direction", which reads - -- oddly against the "From Center" value. - dirDD = BuildMiniDropdown(-100, L["Grow"], DIR_OPTIONS[curOrientation], - function() return curDirection end, - function(val) curDirection = val end - ) - - -- SetEnabled support for disableOn (disable the actual clickable buttons) - container.SetEnabled = function(self, enabled) - local alpha = enabled and 1.0 or 0.4 - self:SetAlpha(alpha) - orientDD.btn:SetEnabled(enabled) - wrapDD.btn:SetEnabled(enabled) - dirDD.btn:SetEnabled(enabled) - end - - -- Refresh from db (e.g., after profile switch) - container.refreshContent = function(self) - curOrientation, curWrap, curDirection = DecomposeGrowth(db[dbKey] or "LEFT_UP") - orientDD:Rebuild(ORIENT_OPTIONS) - wrapDD:Rebuild(WRAP_OPTIONS[curOrientation]) - dirDD:Rebuild(DIR_OPTIONS[curOrientation]) - end - - -- Tooltip: shared attach. This widget has no label of its own — it is three - -- stacked mini dropdowns (Orientation / Wrap / Grow), each built by the local - -- BuildMiniDropdown rather than CreateDropdown, so none of them carries an - -- attach either. The top row's label stands in for the group. - -- - -- ⚠ NOT `lbl`: that name IS in this file, but it is local to - -- BuildMiniDropdown, so reading it here is a nil global — legal Lua, parses - -- clean, and would have silently left this control with no tooltip at all. - GUI:AttachTooltip(container, L["Growth Direction"], orientDD.Label) - - return container -end - --- ============================================================ --- TEXTURE DROPDOWN WITH PREVIEW --- ============================================================ - -function GUI:CreateTextureDropdown(parent, label, dbTable, dbKey, callback, customOptions) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 50) - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) - end - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, -16) - btn:SetPoint("TOPRIGHT", 0, -16) - btn:SetHeight(24) - CreateElementBackdrop(btn) - - -- Texture preview on button - btn.Preview = btn:CreateTexture(nil, "ARTWORK") - btn.Preview:SetPoint("LEFT", 4, 0) - btn.Preview:SetSize(80, 16) - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 90, 0) - btn.Text:SetPoint("RIGHT", -20, 0) - btn.Text:SetJustifyH("LEFT") - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Arrow indicator - local arrow = btn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -8, 0) - arrow:SetSize(12, 12) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local function UpdateText() - if dbTable and dbKey then - local val = dbTable[dbKey] - local displayName - if customOptions then - -- Use custom options lookup - displayName = customOptions[val] - else - -- Use robust SharedMedia lookup - displayName = DF:GetTextureNameFromPath(val) - end - btn.Text:SetText(displayName or L["Select..."]) - -- Handle "Solid" special case (not a valid texture path) - if val == "Solid" then - btn.Preview:SetColorTexture(0.3, 0.3, 0.3, 1) - else - btn.Preview:SetTexture(val) - btn.Preview:SetVertexColor(0.3, 0.7, 0.3) -- Green tint for preview - end - end - end - - -- Menu frame with scroll - local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") - menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) - menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menuFrame) - menuFrame:SetClampedToScreen(true) - CreateElementBackdrop(menuFrame) - menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) - menuFrame:Hide() - - -- Search box at top of menu - local SEARCH_HEIGHT = 26 - local searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") - searchBox:SetPoint("TOPLEFT", 4, -4) - searchBox:SetPoint("TOPRIGHT", -4, -4) - searchBox:SetHeight(22) - searchBox:SetAutoFocus(false) - searchBox:SetFontObject(DFFontHighlightSmall) - searchBox:SetTextInsets(24, 8, 0, 0) - CreateElementBackdrop(searchBox) - searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) - - -- Search icon - local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") - searchIcon:SetPoint("LEFT", 6, 0) - searchIcon:SetSize(12, 12) - searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") - searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Placeholder text - local placeholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - placeholder:SetPoint("LEFT", 24, 0) - placeholder:SetText(L["Search textures..."]) - placeholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - - searchBox:SetScript("OnEditFocusGained", function() placeholder:Hide() end) - searchBox:SetScript("OnEditFocusLost", function() - if searchBox:GetText() == "" then placeholder:Show() end - end) - - -- Clear tracking when hidden - menuFrame:SetScript("OnHide", function() - if S.currentOpenDropdown == menuFrame then - S.currentOpenDropdown = nil - end - searchBox:SetText("") - searchBox:ClearFocus() - placeholder:Show() - end) - - -- Scroll frame - positioned below search box - local scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) - scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) - - local scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollChild:SetWidth(234) -- Match button width for texture dropdown - scrollFrame:SetScrollChild(scrollChild) - - StyleScrollBar(scrollFrame) - - local menuButtons = {} - local ITEM_HEIGHT = 28 - local MAX_VISIBLE = 8 - - -- Function to rebuild menu with current textures - local function RebuildMenu(filterText) - -- Clear old buttons - for _, menuBtn in ipairs(menuButtons) do - menuBtn:Hide() - menuBtn:SetParent(nil) - end - wipe(menuButtons) - - -- Get fresh texture list (use custom options if provided) - local options = customOptions or DF:GetTextureList() - local sortedOptions = {} - - -- Apply filter if provided - filterText = filterText and filterText:lower() or "" - - for k, v in pairs(options) do - if filterText == "" or v:lower():find(filterText, 1, true) then - table.insert(sortedOptions, {key = k, value = v}) - end - end - table.sort(sortedOptions, function(a, b) return a.value < b.value end) - - -- Resize menu and scroll child - local menuHeight = math.min(#sortedOptions, MAX_VISIBLE) * ITEM_HEIGHT + SEARCH_HEIGHT + 8 - menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) - menuFrame:SetHeight(menuHeight) - scrollChild:SetHeight(#sortedOptions * ITEM_HEIGHT) - - -- Hide scrollbar if not needed - if scrollBar then - if #sortedOptions <= MAX_VISIBLE then - scrollBar:Hide() - else - scrollBar:Show() - end - end - - -- Create new buttons - for i, opt in ipairs(sortedOptions) do - local menuBtn = CreateFrame("Button", nil, scrollChild) - menuBtn:SetSize(234, ITEM_HEIGHT) - menuBtn:SetPoint("TOPLEFT", 0, -(i - 1) * ITEM_HEIGHT) - - -- Texture preview - menuBtn.Preview = menuBtn:CreateTexture(nil, "ARTWORK") - menuBtn.Preview:SetPoint("LEFT", 4, 0) - menuBtn.Preview:SetSize(80, 18) - -- Handle "Solid" special case - if opt.key == "Solid" then - menuBtn.Preview:SetColorTexture(0.3, 0.3, 0.3, 1) - else - menuBtn.Preview:SetTexture(opt.key) - menuBtn.Preview:SetVertexColor(0.3, 0.7, 0.3) -- Green tint for preview - end - - menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - menuBtn.Text:SetPoint("LEFT", 90, 0) - menuBtn.Text:SetText(opt.value) - - -- Highlight selected item - if dbTable[dbKey] == opt.key then - menuBtn.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - - menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") - menuBtn.Highlight:SetAllPoints() - local c = GetThemeColor() - menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) - - menuBtn:SetScript("OnClick", function() - -- Runtime override protection - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, opt.key) then - UpdateText() - menuFrame:Hide() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(opt.key) end - return - end - dbTable[dbKey] = opt.key - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, opt.key) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(opt.key) - end - UpdateText() - menuFrame:Hide() - DF:UpdateAll() - if callback then callback() end - end) - - table.insert(menuButtons, menuBtn) - end - end - - -- Search box text changed handler - searchBox:SetScript("OnTextChanged", function(self) - RebuildMenu(self:GetText()) - end) - - -- Allow escape to close - searchBox:SetScript("OnEscapePressed", function() - menuFrame:Hide() - end) - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - btn:SetScript("OnClick", function(self) - if menuFrame:IsShown() then - menuFrame:Hide() - S.currentOpenDropdown = nil - else - -- Close any other open dropdown first - CloseOpenDropdown() - -- Rebuild menu with current SharedMedia textures - RebuildMenu() - menuFrame:Show() - S.currentOpenDropdown = menuFrame - -- Focus search box - searchBox:SetFocus() - end - end) - - btn:SetScript("OnShow", UpdateText) - UpdateText() - - -- Refresh override indicators on show - container:SetScript("OnShow", function() - UpdateText() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so its preview/value (texture swatch, font preview, - -- selected text) greys with the label rather than staying full-bright. - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- SEARCH: Register this setting (use current texture list) - if DF.Search and dbKey and type(dbKey) == "string" then - local currentOptions = customOptions or DF:GetTextureList() - container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, currentOptions, nil, callback) - end - - -- Tooltip: shared attach on the LABEL only. Hand-rolled preview dropdown, so - -- it never picked up CreateDropdown's tooltip support. - GUI:AttachTooltip(container, label or L["Texture"], lbl) - - return container -end - --- ============================================================ --- FONT DROPDOWN WITH PREVIEW --- ============================================================ - --- inheritKey (optional): when dbTable[dbKey] is nil (no per-element override), --- the dropdown DISPLAYS dbTable[inheritKey] instead so it shows the inherited --- (e.g. global) font. Selecting a font still writes dbKey (the override). -function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback, inheritKey) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 50) - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) - end - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, -16) - btn:SetPoint("TOPRIGHT", 0, -16) - btn:SetHeight(24) - CreateElementBackdrop(btn) - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 8, 0) - btn.Text:SetPoint("RIGHT", -20, 0) - btn.Text:SetJustifyH("LEFT") - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Arrow indicator - local arrow = btn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -8, 0) - arrow:SetSize(12, 12) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local function UpdateText() - if dbTable and dbKey then - local val = dbTable[dbKey] or (inheritKey and dbTable[inheritKey]) - -- Get font display name (handles both names and legacy paths) - local displayName = DF:GetFontNameFromPath(val) - btn.Text:SetText(displayName or L["Select..."]) - -- Try to set the button text to the selected font for preview - local fontPath = DF:GetFontPath(val) - if fontPath then - local success = pcall(function() - btn.Text:SetFont(fontPath, 12, "") - end) - if not success then - btn.Text:SetFontObject(DFFontHighlightSmall) - end - end - end - end - - -- Menu frame with scroll - local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") - menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) - menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menuFrame) - menuFrame:SetClampedToScreen(true) - CreateElementBackdrop(menuFrame) - menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) - menuFrame:Hide() - - -- Search box at top of menu - local SEARCH_HEIGHT = 26 - local searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") - searchBox:SetPoint("TOPLEFT", 4, -4) - searchBox:SetPoint("TOPRIGHT", -4, -4) - searchBox:SetHeight(22) - searchBox:SetAutoFocus(false) - searchBox:SetFontObject(DFFontHighlightSmall) - searchBox:SetTextInsets(24, 8, 0, 0) - CreateElementBackdrop(searchBox) - searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) - - -- Search icon - local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") - searchIcon:SetPoint("LEFT", 6, 0) - searchIcon:SetSize(12, 12) - searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") - searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Placeholder text - local placeholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - placeholder:SetPoint("LEFT", 24, 0) - placeholder:SetText(L["Search fonts..."]) - placeholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - - searchBox:SetScript("OnEditFocusGained", function() placeholder:Hide() end) - searchBox:SetScript("OnEditFocusLost", function() - if searchBox:GetText() == "" then placeholder:Show() end - end) - - -- Clear tracking when hidden - menuFrame:SetScript("OnHide", function() - if S.currentOpenDropdown == menuFrame then - S.currentOpenDropdown = nil - end - searchBox:SetText("") - searchBox:ClearFocus() - placeholder:Show() - end) - - -- Scroll frame - positioned below search box - local scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) - scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) - - local scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollChild:SetWidth(234) -- Match button width for font dropdown - scrollFrame:SetScrollChild(scrollChild) - - StyleScrollBar(scrollFrame) - - local menuButtons = {} - local ITEM_HEIGHT = 24 - local MAX_VISIBLE = 10 - - -- Function to rebuild menu with current fonts - local function RebuildMenu(filterText) - -- Clear old buttons - for _, menuBtn in ipairs(menuButtons) do - menuBtn:Hide() - menuBtn:SetParent(nil) - end - wipe(menuButtons) - - -- Get fresh font list - local options = DF:GetFontList() - local sortedOptions = {} - - -- Apply filter if provided - filterText = filterText and filterText:lower() or "" - - for k, v in pairs(options) do - if filterText == "" or v:lower():find(filterText, 1, true) then - table.insert(sortedOptions, {key = k, value = v}) - end - end - table.sort(sortedOptions, function(a, b) return a.value < b.value end) - - -- Resize menu and scroll child - local menuHeight = math.min(#sortedOptions, MAX_VISIBLE) * ITEM_HEIGHT + SEARCH_HEIGHT + 8 - menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) - menuFrame:SetHeight(menuHeight) - scrollChild:SetHeight(#sortedOptions * ITEM_HEIGHT) - - -- Hide scrollbar if not needed - if scrollBar then - if #sortedOptions <= MAX_VISIBLE then - scrollBar:Hide() - else - scrollBar:Show() - end - end - - -- Create new buttons - for i, opt in ipairs(sortedOptions) do - local menuBtn = CreateFrame("Button", nil, scrollChild) - menuBtn:SetSize(234, ITEM_HEIGHT) - menuBtn:SetPoint("TOPLEFT", 0, -(i - 1) * ITEM_HEIGHT) - - menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY") - menuBtn.Text:SetPoint("LEFT", 8, 0) - menuBtn.Text:SetPoint("RIGHT", -8, 0) - menuBtn.Text:SetJustifyH("LEFT") - - -- Set default font first, then try to use the actual font for preview - menuBtn.Text:SetFontObject(DFFontHighlightSmall) - - -- Try to preview in the actual font - local LSM = DF.GetLSM and DF.GetLSM() - if LSM then - local fontPath = LSM:Fetch("font", opt.key) - if fontPath then - pcall(function() - menuBtn.Text:SetFont(fontPath, 12, "") - end) - end - end - - menuBtn.Text:SetText(opt.value) - - -- Highlight selected item (compare with stored font name) - local currentValue = dbTable[dbKey] - local currentName = DF:GetFontNameFromPath(currentValue) - if currentName == opt.key or currentValue == opt.key then - menuBtn.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - - menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") - menuBtn.Highlight:SetAllPoints() - local c = GetThemeColor() - menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) - - menuBtn:SetScript("OnClick", function() - -- Runtime override protection - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, opt.key) then - UpdateText() - menuFrame:Hide() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(opt.key) end - return - end - -- Store font NAME in database (not path) - dbTable[dbKey] = opt.key - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, opt.key) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(opt.key) - end - UpdateText() - menuFrame:Hide() - DF:UpdateAll() - if callback then callback() end - end) - - table.insert(menuButtons, menuBtn) - end - end - - -- Search box text changed handler - searchBox:SetScript("OnTextChanged", function(self) - RebuildMenu(self:GetText()) - end) - - -- Allow escape to close - searchBox:SetScript("OnEscapePressed", function() - menuFrame:Hide() - end) - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - btn:SetScript("OnClick", function(self) - if menuFrame:IsShown() then - menuFrame:Hide() - S.currentOpenDropdown = nil - else - -- Close any other open dropdown first - CloseOpenDropdown() - -- Rebuild menu with current SharedMedia fonts - RebuildMenu() - menuFrame:Show() - S.currentOpenDropdown = menuFrame - -- Focus search box - searchBox:SetFocus() - end - end) - - btn:SetScript("OnShow", UpdateText) - UpdateText() - - -- Refresh override indicators on show - container:SetScript("OnShow", function() - UpdateText() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so its preview/value (texture swatch, font preview, - -- selected text) greys with the label rather than staying full-bright. - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- SEARCH: Register this setting (use current font list) - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, DF:GetFontList(), nil, callback) - end - - -- Tooltip: shared attach on the LABEL only. Hand-rolled preview dropdown, so - -- it never picked up CreateDropdown's tooltip support. - GUI:AttachTooltip(container, label or L["Font"], lbl) - - return container -end - --- ============================================================ --- SOUND DROPDOWN (Searchable, scrollable — mirrors font dropdown) --- ============================================================ - -function GUI:CreateSoundDropdown(parent, label, dbTable, dbKey, callback) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 50) - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Button - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, -16) - btn:SetPoint("TOPRIGHT", 0, -16) - btn:SetHeight(24) - CreateElementBackdrop(btn) - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 8, 0) - btn.Text:SetPoint("RIGHT", -20, 0) - btn.Text:SetJustifyH("LEFT") - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Arrow indicator - local arrow = btn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -8, 0) - arrow:SetSize(12, 12) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local function UpdateText() - if dbTable and dbKey then - local val = dbTable[dbKey] - btn.Text:SetText(val or L["Select..."]) - end - end - - -- Menu frame with scroll - local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") - menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) - menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menuFrame) - menuFrame:SetClampedToScreen(true) - CreateElementBackdrop(menuFrame) - menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) - menuFrame:Hide() - - -- Search box at top of menu - local SEARCH_HEIGHT = 26 - local searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") - searchBox:SetPoint("TOPLEFT", 4, -4) - searchBox:SetPoint("TOPRIGHT", -4, -4) - searchBox:SetHeight(22) - searchBox:SetAutoFocus(false) - searchBox:SetFontObject(DFFontHighlightSmall) - searchBox:SetTextInsets(24, 8, 0, 0) - CreateElementBackdrop(searchBox) - searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) - - -- Search icon - local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") - searchIcon:SetPoint("LEFT", 6, 0) - searchIcon:SetSize(12, 12) - searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") - searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Placeholder text - local searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - searchPlaceholder:SetPoint("LEFT", 24, 0) - searchPlaceholder:SetText(L["Search sounds..."]) - searchPlaceholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - - searchBox:SetScript("OnEditFocusGained", function() searchPlaceholder:Hide() end) - searchBox:SetScript("OnEditFocusLost", function() - if searchBox:GetText() == "" then searchPlaceholder:Show() end - end) - - menuFrame:SetScript("OnHide", function() - if S.currentOpenDropdown == menuFrame then - S.currentOpenDropdown = nil - end - searchBox:SetText("") - searchBox:ClearFocus() - searchPlaceholder:Show() - end) - - -- Scroll frame - local scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) - scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) - - local scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollChild:SetWidth(234) - scrollFrame:SetScrollChild(scrollChild) - - StyleScrollBar(scrollFrame) - - local menuButtons = {} - local ITEM_HEIGHT = 22 - local MAX_VISIBLE = 10 - - local function RebuildMenu(filterText) - for _, menuBtn in ipairs(menuButtons) do - menuBtn:Hide() - menuBtn:SetParent(nil) - end - wipe(menuButtons) - - local options = DF:GetSoundList() - local sortedOptions = {} - - filterText = filterText and filterText:lower() or "" - - for k, v in pairs(options) do - if filterText == "" or v:lower():find(filterText, 1, true) then - table.insert(sortedOptions, {key = k, value = v}) - end - end - table.sort(sortedOptions, function(a, b) return a.value < b.value end) - - local menuHeight = math.min(#sortedOptions, MAX_VISIBLE) * ITEM_HEIGHT + SEARCH_HEIGHT + 8 - menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) - menuFrame:SetHeight(menuHeight) - scrollChild:SetHeight(#sortedOptions * ITEM_HEIGHT) - - if scrollBar then - if #sortedOptions <= MAX_VISIBLE then - scrollBar:Hide() - else - scrollBar:Show() - end - end - - for i, opt in ipairs(sortedOptions) do - local menuBtn = CreateFrame("Button", nil, scrollChild) - menuBtn:SetSize(234, ITEM_HEIGHT) - menuBtn:SetPoint("TOPLEFT", 0, -(i - 1) * ITEM_HEIGHT) - - menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - menuBtn.Text:SetPoint("LEFT", 8, 0) - menuBtn.Text:SetPoint("RIGHT", -8, 0) - menuBtn.Text:SetJustifyH("LEFT") - menuBtn.Text:SetText(opt.value) - - -- Highlight selected item - local currentValue = dbTable[dbKey] - if currentValue == opt.key then - menuBtn.Text:SetTextColor(GetThemeColor().r, GetThemeColor().g, GetThemeColor().b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - - menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") - menuBtn.Highlight:SetAllPoints() - local c = GetThemeColor() - menuBtn.Highlight:SetColorTexture(c.r, c.g, c.b, 0.3) - - menuBtn:SetScript("OnClick", function() - dbTable[dbKey] = opt.key - UpdateText() - menuFrame:Hide() - DF:UpdateAll() - if callback then callback() end - end) - - table.insert(menuButtons, menuBtn) - end - end - - searchBox:SetScript("OnTextChanged", function(self) - RebuildMenu(self:GetText()) - end) - - searchBox:SetScript("OnEscapePressed", function() - menuFrame:Hide() - end) - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - btn:SetScript("OnClick", function(self) - if menuFrame:IsShown() then - menuFrame:Hide() - S.currentOpenDropdown = nil - else - CloseOpenDropdown() - RebuildMenu() - menuFrame:Show() - S.currentOpenDropdown = menuFrame - searchBox:SetFocus() - end - end) - - btn:SetScript("OnShow", UpdateText) - UpdateText() - - return container -end - --- ============================================================ --- ROLE ORDER LIST (Drag-Drop) --- ============================================================ - -function GUI:CreateRoleOrderList(parent, dbTable, dbKey, callback, separateMeleeRangedKey) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(220, 130) - - -- Role display info with colors - local ROLE_INFO = { - TANK = { name = L["Tank"], color = {0.53, 0.77, 0.84}, coords = {0, 19/64, 22/64, 41/64} }, - HEALER = { name = L["Healer"], color = {0.25, 0.78, 0.25}, coords = {20/64, 39/64, 1/64, 20/64} }, - MELEE = { name = L["Melee DPS"], color = {0.82, 0.65, 0.47}, coords = {20/64, 39/64, 22/64, 41/64} }, - RANGED = { name = L["Ranged DPS"], color = {1.0, 0.49, 0.04}, coords = {20/64, 39/64, 22/64, 41/64} }, - DAMAGER = { name = L["DPS"], color = {0.82, 0.65, 0.47}, coords = {20/64, 39/64, 22/64, 41/64} }, - } - - local roleItems = {} - -- Snapped stride + gap: a raw 30-unit stride is 42.19 device px, so every - -- row would sit on a different sub-pixel phase and the error would - -- ACCUMULATE down the list (row 3 off by twice row 2). Rows are anchored - -- by two corners, so nothing corrects them after the fact. - local ITEM_HEIGHT = SnapLen(parent, 30) or 30 - local ITEM_GAP = SnapLen(parent, 2) or 2 - local draggingItem = nil - local dragOffsetY = 0 - - -- Check if we should show separate melee/ranged - local function IsSeparateMeleeRanged() - if separateMeleeRangedKey and dbTable then - return dbTable[separateMeleeRangedKey] - end - return true - end - - -- Get the roles to display - local function GetDisplayRoles() - if IsSeparateMeleeRanged() then - return { "TANK", "HEALER", "MELEE", "RANGED" } - else - return { "TANK", "HEALER", "DAMAGER" } - end - end - - -- Get current order from db or use default - local function GetCurrentOrder() - local displayRoles = GetDisplayRoles() - if dbTable and dbKey and dbTable[dbKey] then - local order = {} - for _, role in ipairs(dbTable[dbKey]) do - for _, displayRole in ipairs(displayRoles) do - if role == displayRole or - (displayRole == "DAMAGER" and (role == "MELEE" or role == "RANGED" or role == "DAMAGER")) then - local found = false - for _, existing in ipairs(order) do - if existing == displayRole then found = true break end - end - if not found then - table.insert(order, displayRole) - end - break - end - end - end - for _, displayRole in ipairs(displayRoles) do - local found = false - for _, existing in ipairs(order) do - if existing == displayRole then found = true break end - end - if not found then - table.insert(order, displayRole) - end - end - return order - end - return displayRoles - end - - -- Save order to db - local function SaveOrder(newOrder) - if dbTable and dbKey then - local saveOrder = {} - for _, role in ipairs(newOrder) do - if role == "DAMAGER" then - table.insert(saveOrder, "MELEE") - table.insert(saveOrder, "RANGED") - else - table.insert(saveOrder, role) - end - end - dbTable[dbKey] = saveOrder - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - local copy = {} - for i, v in ipairs(saveOrder) do copy[i] = v end - DF.AutoProfilesUI:SetProfileSetting(dbKey, copy) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(saveOrder) - end - if callback then callback() end - end - end - - -- Get index from Y position - local function GetIndexFromY(y) - local containerTop = container:GetTop() - if not containerTop then return 1 end - local relativeY = containerTop - y - local index = math.floor(relativeY / ITEM_HEIGHT) + 1 - local order = GetCurrentOrder() - return math.max(1, math.min(index, #order)) - end - - -- Update visual positions - local function UpdateItemPositions() - local order = GetCurrentOrder() - local numRoles = #order - - container:SetHeight(numRoles * ITEM_HEIGHT + (SnapLen(container, 5) or 5)) - - for _, item in pairs(roleItems) do - item:Hide() - end - - for i, role in ipairs(order) do - local item = roleItems[role] - if item then - item:Show() - item.posIndex = i - item.numText:SetText(i .. ".") - if item ~= draggingItem then - -- Anchored to BOTH sides: the container is created at a placeholder - -- width and only stretched to its real one by the settings group's - -- LayoutChildren, so a width captured here would be stale. Deriving it - -- from the anchors keeps the rows correct at every layout. - item:ClearAllPoints() - item:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) - item:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) - end - end - end - end - - -- Create grip texture (3 horizontal lines) - local function CreateGripTexture(parentFrame) - local grip = CreateFrame("Frame", nil, parentFrame) - grip:SetSize(12, 16) - - local icon = grip:CreateTexture(nil, "ARTWORK") - icon:SetAllPoints(grip) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") - icon:SetVertexColor(0.5, 0.5, 0.5, 1) - grip.icon = icon - - grip.SetGripColor = function(self, r, g, b) - self.icon:SetVertexColor(r, g, b, 1) - end - - return grip - end - - -- Create a single role item - local function CreateRoleItem(role) - local info = ROLE_INFO[role] - if not info then return nil end - - local item = CreateFrame("Frame", nil, container, "BackdropTemplate") - item:SetHeight(ITEM_HEIGHT - ITEM_GAP) - item:EnableMouse(true) - CreateElementBackdrop(item, { - bgColor = { 0.12, 0.12, 0.12, 0.9 }, - borderColor = { 0.3, 0.3, 0.3, 1 }, - }) - - -- Grip texture - local grip = CreateGripTexture(item) - grip:SetPoint("LEFT", 6, 0) - item.grip = grip - - -- Priority number - local numText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlight") - numText:SetPoint("LEFT", grip, "RIGHT", 6, 0) - numText:SetWidth(18) - numText:SetJustifyH("LEFT") - item.numText = numText - - -- Role icon - local icon = item:CreateTexture(nil, "ARTWORK") - icon:SetSize(16, 16) - icon:SetPoint("LEFT", numText, "RIGHT", 2, 0) - icon:SetTexture("Interface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES") - icon:SetTexCoord(unpack(info.coords)) - item.icon = icon - - -- Role name with color - local text = item:CreateFontString(nil, "OVERLAY", "DFFontNormal") - text:SetPoint("LEFT", icon, "RIGHT", 6, 0) - text:SetText(info.name) - text:SetTextColor(info.color[1], info.color[2], info.color[3]) - item.text = text - - item.role = role - item.posIndex = 1 - - -- Mouse handlers for dragging - item:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - draggingItem = self - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local itemTop = self:GetTop() - dragOffsetY = itemTop - cursorY - - local tc = GetThemeColor() - self:SetBackdropColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) - self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) - self:SetFrameLevel(container:GetFrameLevel() + 10) - self.grip:SetGripColor(1, 1, 1) - end - end) - - item:SetScript("OnMouseUp", function(self, button) - if button == "LeftButton" and draggingItem == self then - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local dropIndex = GetIndexFromY(cursorY) - - local order = GetCurrentOrder() - local currentIdx = self.posIndex - - if currentIdx ~= dropIndex then - local draggedRole = self.role - table.remove(order, currentIdx) - table.insert(order, dropIndex, draggedRole) - SaveOrder(order) - end - - self:SetBackdropColor(0.12, 0.12, 0.12, 0.9) - self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) - self:SetFrameLevel(container:GetFrameLevel() + 1) - self.grip:SetGripColor(0.5, 0.5, 0.5) - - draggingItem = nil - UpdateItemPositions() - end - end) - - item:SetScript("OnUpdate", function(self) - if draggingItem ~= self then return end - - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local containerTop = container:GetTop() - local containerBottom = container:GetBottom() - - if not containerTop or not containerBottom then return end - - local targetY = cursorY + dragOffsetY - local offsetFromTop = containerTop - targetY - - local maxOffset = (containerTop - containerBottom) - ITEM_HEIGHT + 5 - offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) - - self:ClearAllPoints() - self:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -offsetFromTop) - self:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -offsetFromTop) - - -- Update other items based on where this would drop - local dropIndex = GetIndexFromY(cursorY) - local order = GetCurrentOrder() - - local tempOrder = {} - for i, r in ipairs(order) do - if roleItems[r] ~= self then - table.insert(tempOrder, r) - end - end - table.insert(tempOrder, dropIndex, self.role) - - for i, r in ipairs(tempOrder) do - local otherItem = roleItems[r] - if otherItem and otherItem ~= self then - otherItem:ClearAllPoints() - otherItem:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) - otherItem:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) - otherItem.numText:SetText(i .. ".") - end - end - end) - - -- Hover effects - item:SetScript("OnEnter", function(self) - if not draggingItem then - self:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) - self.grip:SetGripColor(0.8, 0.8, 0.8) - end - end) - - item:SetScript("OnLeave", function(self) - if draggingItem ~= self then - self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) - self.grip:SetGripColor(0.5, 0.5, 0.5) - end - end) - - return item - end - - -- Create all role items - for _, role in ipairs({"TANK", "HEALER", "MELEE", "RANGED", "DAMAGER"}) do - roleItems[role] = CreateRoleItem(role) - end - - -- Initial layout - UpdateItemPositions() - - -- Refresh function - container.Refresh = function() - UpdateItemPositions() - end - - -- Override indicators for profile editing - if dbKey and type(dbKey) == "string" and not (dbTable and rawget(dbTable, "_skipOverrideIndicators")) then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and type(globalVal) == "table" then - local copy = {} - for i, v in ipairs(globalVal) do copy[i] = v end - dbTable[dbKey] = copy - end - UpdateItemPositions() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable[dbKey]) - end - if callback then callback() end - end - end - AddOrderListOverrideIndicators(container, dbKey, onReset) - - container:SetScript("OnShow", function() - UpdateItemPositions() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - end) - end - - return container -end - --- ============================================================ --- CLASS ORDER LIST (Drag-Drop) - For class sorting within roles --- ============================================================ - -function GUI:CreateClassOrderList(parent, dbTable, dbKey, callback) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(220, 340) -- Taller to fit all 13 classes - - -- Class display info with colors (using Blizzard class colors) - local CLASS_INFO = { - DEATHKNIGHT = { name = L["Death Knight"], color = {0.77, 0.12, 0.23} }, - DEMONHUNTER = { name = L["Demon Hunter"], color = {0.64, 0.19, 0.79} }, - DRUID = { name = L["Druid"], color = {1.0, 0.49, 0.04} }, - EVOKER = { name = L["Evoker"], color = {0.20, 0.58, 0.50} }, - HUNTER = { name = L["Hunter"], color = {0.67, 0.83, 0.45} }, - MAGE = { name = L["Mage"], color = {0.25, 0.78, 0.92} }, - MONK = { name = L["Monk"], color = {0.0, 1.0, 0.59} }, - PALADIN = { name = L["Paladin"], color = {0.96, 0.55, 0.73} }, - PRIEST = { name = L["Priest"], color = {1.0, 1.0, 1.0} }, - ROGUE = { name = L["Rogue"], color = {1.0, 0.96, 0.41} }, - SHAMAN = { name = L["Shaman"], color = {0.0, 0.44, 0.87} }, - WARLOCK = { name = L["Warlock"], color = {0.53, 0.53, 0.93} }, - WARRIOR = { name = L["Warrior"], color = {0.78, 0.61, 0.43} }, - } - - local ALL_CLASSES = { - "DEATHKNIGHT", "DEMONHUNTER", "DRUID", "EVOKER", "HUNTER", - "MAGE", "MONK", "PALADIN", "PRIEST", "ROGUE", - "SHAMAN", "WARLOCK", "WARRIOR" - } - - local classItems = {} - -- Snapped; see CreateRoleOrderList. - local ITEM_HEIGHT = SnapLen(parent, 24) or 24 -- smaller, to fit all classes - local ITEM_GAP = SnapLen(parent, 2) or 2 - local draggingItem = nil - local dragOffsetY = 0 - - -- Get current order from db or use default - local function GetCurrentOrder() - if dbTable and dbKey and dbTable[dbKey] then - -- Ensure all classes are present - local order = {} - local seen = {} - for _, class in ipairs(dbTable[dbKey]) do - if CLASS_INFO[class] and not seen[class] then - table.insert(order, class) - seen[class] = true - end - end - -- Add any missing classes - for _, class in ipairs(ALL_CLASSES) do - if not seen[class] then - table.insert(order, class) - end - end - return order - end - return ALL_CLASSES - end - - -- Save order to db - local function SaveOrder(newOrder) - if dbTable and dbKey then - dbTable[dbKey] = newOrder - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - local copy = {} - for i, v in ipairs(newOrder) do copy[i] = v end - DF.AutoProfilesUI:SetProfileSetting(dbKey, copy) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(newOrder) - end - if callback then callback() end - end - end - - -- Get index from Y position - local function GetIndexFromY(y) - local containerTop = container:GetTop() - if not containerTop then return 1 end - local relativeY = containerTop - y - local index = math.floor(relativeY / ITEM_HEIGHT) + 1 - local order = GetCurrentOrder() - return math.max(1, math.min(index, #order)) - end - - -- Update visual positions - local function UpdateItemPositions() - local order = GetCurrentOrder() - local numClasses = #order - - container:SetHeight(numClasses * ITEM_HEIGHT + (SnapLen(container, 5) or 5)) - - for _, item in pairs(classItems) do - item:Hide() - end - - for i, class in ipairs(order) do - local item = classItems[class] - if item then - item:Show() - item.posIndex = i - item.numText:SetText(i .. ".") - if item ~= draggingItem then - -- Anchored to BOTH sides: the container is created at a placeholder - -- width and only stretched to its real one by the settings group's - -- LayoutChildren, so a width captured here would be stale. Deriving it - -- from the anchors keeps the rows correct at every layout. - item:ClearAllPoints() - item:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) - item:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) - end - end - end - end - - -- Create grip texture (3 horizontal lines) - local function CreateGripTexture(parentFrame) - local grip = CreateFrame("Frame", nil, parentFrame) - grip:SetSize(10, 12) - - local icon = grip:CreateTexture(nil, "ARTWORK") - icon:SetAllPoints(grip) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") - icon:SetVertexColor(0.5, 0.5, 0.5, 1) - grip.icon = icon - - grip.SetGripColor = function(self, r, g, b) - self.icon:SetVertexColor(r, g, b, 1) - end - - return grip - end - - -- Create a single class item - local function CreateClassItem(class) - local info = CLASS_INFO[class] - if not info then return nil end - - local item = CreateFrame("Frame", nil, container, "BackdropTemplate") - item:SetHeight(ITEM_HEIGHT - ITEM_GAP) - item:EnableMouse(true) - CreateElementBackdrop(item, { - bgColor = { 0.12, 0.12, 0.12, 0.9 }, - borderColor = { 0.3, 0.3, 0.3, 1 }, - }) - - -- Grip texture - local grip = CreateGripTexture(item) - grip:SetPoint("LEFT", 4, 0) - item.grip = grip - - -- Priority number - local numText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - numText:SetPoint("LEFT", grip, "RIGHT", 4, 0) - numText:SetWidth(20) - numText:SetJustifyH("LEFT") - item.numText = numText - - -- Class color bar - local colorBar = item:CreateTexture(nil, "ARTWORK") - colorBar:SetSize(3, ITEM_HEIGHT - 6) - colorBar:SetPoint("LEFT", numText, "RIGHT", 2, 0) - colorBar:SetColorTexture(info.color[1], info.color[2], info.color[3], 1) - item.colorBar = colorBar - - -- Class name with color - local text = item:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") - text:SetPoint("LEFT", colorBar, "RIGHT", 6, 0) - text:SetText(info.name) - text:SetTextColor(info.color[1], info.color[2], info.color[3]) - item.text = text - - item.class = class - item.posIndex = 1 - - -- Mouse handlers for dragging - item:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - draggingItem = self - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local itemTop = self:GetTop() - dragOffsetY = itemTop - cursorY - - local tc = GetThemeColor() - self:SetBackdropColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) - self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) - self:SetFrameLevel(container:GetFrameLevel() + 10) - self.grip:SetGripColor(1, 1, 1) - end - end) - - item:SetScript("OnMouseUp", function(self, button) - if button == "LeftButton" and draggingItem == self then - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local dropIndex = GetIndexFromY(cursorY) - - local order = GetCurrentOrder() - local currentIdx = self.posIndex - - if currentIdx ~= dropIndex then - local draggedClass = self.class - table.remove(order, currentIdx) - table.insert(order, dropIndex, draggedClass) - SaveOrder(order) - end - - self:SetBackdropColor(0.12, 0.12, 0.12, 0.9) - self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) - self:SetFrameLevel(container:GetFrameLevel() + 1) - self.grip:SetGripColor(0.5, 0.5, 0.5) - - draggingItem = nil - UpdateItemPositions() - end - end) - - item:SetScript("OnUpdate", function(self) - if draggingItem ~= self then return end - - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local containerTop = container:GetTop() - local containerBottom = container:GetBottom() - - if not containerTop or not containerBottom then return end - - local targetY = cursorY + dragOffsetY - local offsetFromTop = containerTop - targetY - - local maxOffset = (containerTop - containerBottom) - ITEM_HEIGHT + 5 - offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) - - self:ClearAllPoints() - self:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -offsetFromTop) - self:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -offsetFromTop) - - -- Update other items based on where this would drop - local dropIndex = GetIndexFromY(cursorY) - local order = GetCurrentOrder() - - local tempOrder = {} - for i, c in ipairs(order) do - if classItems[c] ~= self then - table.insert(tempOrder, c) - end - end - table.insert(tempOrder, dropIndex, self.class) - - for i, c in ipairs(tempOrder) do - local otherItem = classItems[c] - if otherItem and otherItem ~= self then - otherItem:ClearAllPoints() - otherItem:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) - otherItem:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) - otherItem.numText:SetText(i .. ".") - end - end - end) - - -- Hover effects - item:SetScript("OnEnter", function(self) - if not draggingItem then - self:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) - self.grip:SetGripColor(0.8, 0.8, 0.8) - end - end) - - item:SetScript("OnLeave", function(self) - if draggingItem ~= self then - self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) - self.grip:SetGripColor(0.5, 0.5, 0.5) - end - end) - - return item - end - - -- Create all class items - for _, class in ipairs(ALL_CLASSES) do - classItems[class] = CreateClassItem(class) - end - - -- Initial layout - UpdateItemPositions() - - -- Refresh function - container.Refresh = function() - UpdateItemPositions() - end - - -- Override indicators for profile editing - if dbKey and type(dbKey) == "string" and not (dbTable and rawget(dbTable, "_skipOverrideIndicators")) then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and type(globalVal) == "table" then - local copy = {} - for i, v in ipairs(globalVal) do copy[i] = v end - dbTable[dbKey] = copy - end - UpdateItemPositions() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable[dbKey]) - end - if callback then callback() end - end - end - AddOrderListOverrideIndicators(container, dbKey, onReset) - - container:SetScript("OnShow", function() - UpdateItemPositions() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - end) - end - - return container -end - --- Raid Group Order List (drag-and-drop) -function GUI:CreateGroupOrderList(parent, dbTable, dbKey, callback, playerGroupFirstKey) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(180, 250) - - -- Group colors for visual distinction - local GROUP_COLORS = { - [1] = {0.95, 0.40, 0.40}, -- Red - [2] = {0.40, 0.95, 0.40}, -- Green - [3] = {0.40, 0.60, 0.95}, -- Blue - [4] = {0.95, 0.95, 0.40}, -- Yellow - [5] = {0.95, 0.40, 0.95}, -- Magenta - [6] = {0.40, 0.95, 0.95}, -- Cyan - [7] = {0.95, 0.70, 0.40}, -- Orange - [8] = {0.70, 0.40, 0.95}, -- Purple - } - - local groupItems = {} - -- Snapped; see CreateRoleOrderList. - local ITEM_HEIGHT = SnapLen(parent, 28) or 28 - local ITEM_GAP = SnapLen(parent, 2) or 2 - local draggingItem = nil - local dragOffsetY = 0 - - -- Get current order from db or use default - local function GetCurrentOrder() - if dbTable and dbKey and dbTable[dbKey] then - -- Validate and return existing order - local order = {} - local seen = {} - for _, groupNum in ipairs(dbTable[dbKey]) do - if groupNum >= 1 and groupNum <= 8 and not seen[groupNum] then - table.insert(order, groupNum) - seen[groupNum] = true - end - end - -- Add any missing groups - for i = 1, 8 do - if not seen[i] then - table.insert(order, i) - end - end - return order - end - return {1, 2, 3, 4, 5, 6, 7, 8} - end - - -- Save order to db - local function SaveOrder(newOrder) - if dbTable and dbKey then - dbTable[dbKey] = newOrder - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - local copy = {} - for i, v in ipairs(newOrder) do copy[i] = v end - DF.AutoProfilesUI:SetProfileSetting(dbKey, copy) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(newOrder) - end - if callback then callback() end - end - end - - -- Get index from Y position - local function GetIndexFromY(y) - local containerTop = container:GetTop() - if not containerTop then return 1 end - local relativeY = containerTop - y - local index = math.floor(relativeY / ITEM_HEIGHT) + 1 - return math.max(1, math.min(index, 8)) - end - - -- Update visual positions - local function UpdateItemPositions() - local order = GetCurrentOrder() - - for _, item in pairs(groupItems) do - item:Hide() - end - - for displayPos, groupNum in ipairs(order) do - local item = groupItems[groupNum] - if item then - item:Show() - item.displayPos = displayPos - item.numText:SetText(displayPos .. ".") - if item ~= draggingItem then - -- Anchored to BOTH sides: the container is created at a placeholder - -- width and only stretched to its real one by the settings group's - -- LayoutChildren, so a width captured here would be stale. Deriving it - -- from the anchors keeps the rows correct at every layout. - item:ClearAllPoints() - item:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((displayPos - 1) * ITEM_HEIGHT)) - item:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((displayPos - 1) * ITEM_HEIGHT)) - end - end - end - end - - -- Create grip texture (3 horizontal lines) - local function CreateGripTexture(parentFrame) - local grip = CreateFrame("Frame", nil, parentFrame) - grip:SetSize(12, 14) - - local icon = grip:CreateTexture(nil, "ARTWORK") - icon:SetAllPoints(grip) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") - icon:SetVertexColor(0.5, 0.5, 0.5, 1) - grip.icon = icon - - grip.SetGripColor = function(self, r, g, b) - self.icon:SetVertexColor(r, g, b, 1) - end - - return grip - end - - -- Create a single group item - local function CreateGroupItem(groupNum) - local color = GROUP_COLORS[groupNum] - - local item = CreateFrame("Frame", nil, container, "BackdropTemplate") - item:SetHeight(ITEM_HEIGHT - ITEM_GAP) - item:EnableMouse(true) - CreateElementBackdrop(item, { - bgColor = { 0.12, 0.12, 0.12, 0.9 }, - borderColor = { 0.3, 0.3, 0.3, 1 }, - }) - - -- Grip texture - local grip = CreateGripTexture(item) - grip:SetPoint("LEFT", 6, 0) - item.grip = grip - - -- Display position number - local numText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlight") - numText:SetPoint("LEFT", grip, "RIGHT", 6, 0) - numText:SetWidth(18) - numText:SetJustifyH("LEFT") - item.numText = numText - - -- Color swatch - local swatch = item:CreateTexture(nil, "ARTWORK") - swatch:SetSize(14, 14) - swatch:SetPoint("LEFT", numText, "RIGHT", 4, 0) - swatch:SetColorTexture(color[1], color[2], color[3], 1) - item.swatch = swatch - - -- Group name - local text = item:CreateFontString(nil, "OVERLAY", "DFFontNormal") - text:SetPoint("LEFT", swatch, "RIGHT", 6, 0) - text:SetText(string.format(L["Group %d"], groupNum)) - text:SetTextColor(color[1], color[2], color[3]) - item.text = text - - item.groupNum = groupNum - item.displayPos = groupNum - - -- Mouse handlers for dragging - item:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - draggingItem = self - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local itemTop = self:GetTop() - dragOffsetY = itemTop - cursorY - - local tc = GetThemeColor() - self:SetBackdropColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) - self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) - self:SetFrameLevel(container:GetFrameLevel() + 10) - self.grip:SetGripColor(1, 1, 1) - end - end) - - item:SetScript("OnMouseUp", function(self, button) - if button == "LeftButton" and draggingItem == self then - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local newIndex = GetIndexFromY(cursorY) - - -- Reorder - local currentOrder = GetCurrentOrder() - local oldIndex = self.displayPos - - if newIndex ~= oldIndex then - table.remove(currentOrder, oldIndex) - table.insert(currentOrder, newIndex, self.groupNum) - SaveOrder(currentOrder) - end - - draggingItem = nil - self:SetBackdropColor(0.12, 0.12, 0.12, 0.9) - self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) - self:SetFrameLevel(container:GetFrameLevel() + 1) - self.grip:SetGripColor(0.5, 0.5, 0.5) - - UpdateItemPositions() - end - end) - - item:SetScript("OnUpdate", function(self) - if draggingItem ~= self then return end - - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local containerTop = container:GetTop() - local containerBottom = container:GetBottom() - - if not containerTop or not containerBottom then return end - - local targetY = cursorY + dragOffsetY - local offsetFromTop = containerTop - targetY - - local maxOffset = (containerTop - containerBottom) - ITEM_HEIGHT + 5 - offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) - - self:ClearAllPoints() - self:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -offsetFromTop) - self:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -offsetFromTop) - - -- Update other items based on where this would drop - local dropIndex = GetIndexFromY(cursorY) - local order = GetCurrentOrder() - - -- Build temp order: remove self, insert at drop position - local tempOrder = {} - for i, g in ipairs(order) do - if groupItems[g] ~= self then - table.insert(tempOrder, g) - end - end - table.insert(tempOrder, dropIndex, self.groupNum) - - -- Position all other items according to temp order - for i, g in ipairs(tempOrder) do - local otherItem = groupItems[g] - if otherItem and otherItem ~= self then - otherItem:ClearAllPoints() - otherItem:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -((i - 1) * ITEM_HEIGHT)) - otherItem:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, -((i - 1) * ITEM_HEIGHT)) - otherItem.numText:SetText(i .. ".") - end - end - end) - - item:SetScript("OnEnter", function(self) - if draggingItem ~= self then - self:SetBackdropBorderColor(0.5, 0.5, 0.5, 1) - self.grip:SetGripColor(0.8, 0.8, 0.8) - end - end) - - item:SetScript("OnLeave", function(self) - if draggingItem ~= self then - self:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) - self.grip:SetGripColor(0.5, 0.5, 0.5) - end - end) - - return item - end - - -- Create all group items - for i = 1, 8 do - groupItems[i] = CreateGroupItem(i) - end - - -- Initial layout - UpdateItemPositions() - - -- Refresh function - container.Refresh = function() - UpdateItemPositions() - end - - -- Override indicators for profile editing - if dbKey and type(dbKey) == "string" and not (dbTable and rawget(dbTable, "_skipOverrideIndicators")) then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and type(globalVal) == "table" then - local copy = {} - for i, v in ipairs(globalVal) do copy[i] = v end - dbTable[dbKey] = copy - end - UpdateItemPositions() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable[dbKey]) - end - if callback then callback() end - end - end - AddOrderListOverrideIndicators(container, dbKey, onReset) - - container:SetScript("OnShow", function() - UpdateItemPositions() - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - end) - end - - return container -end - --- ============================================================ --- HIGHLIGHT FRAMES ROSTER WIDGET --- ============================================================ --- Dual-column widget for selecting players to highlight --- Left: Current group roster --- Right: Selected players (draggable for reorder) - -function GUI:CreateHighlightRosterWidget(parent, getPlayersFunc, setPlayersFunc, onChangeCallback) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(460, 340) - - -- Snapped; see CreateRoleOrderList. - local ITEM_HEIGHT = SnapLen(parent, 26) or 26 - local ITEM_GAP = SnapLen(parent, 2) or 2 - local COL_WIDTH = 224 -- Wider columns - local COL_GAP = 12 -- Smaller gap between columns - - -- State - local rosterItems = {} - local highlightItems = {} - local currentRoster = {} - local draggingItem = nil - local dragOffsetY = 0 - - -- Custom role icons - local ROLE_ICONS = { - TANK = "Interface\\AddOns\\DandersFrames\\Media\\DF_Tank", - HEALER = "Interface\\AddOns\\DandersFrames\\Media\\DF_Healer", - DAMAGER = "Interface\\AddOns\\DandersFrames\\Media\\DF_DPS", - } - local ROLE_COLORS = { - TANK = {0.35, 0.56, 0.82}, - HEALER = {0.29, 0.62, 0.29}, - DAMAGER = {0.70, 0.35, 0.35}, - } - - -- Icon paths - local ICON_ARROW = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right" - local ICON_CHECK = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\check" - local ICON_CLOSE = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close" - - -- ========== LEFT COLUMN: Group Roster ========== - local leftHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") - leftHeader:SetPoint("TOPLEFT", 0, 0) - leftHeader:SetText(L["Group Roster"]) - leftHeader:SetTextColor(0.7, 0.7, 0.7) - - local leftCount = container:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") - leftCount:SetPoint("LEFT", leftHeader, "RIGHT", 8, 0) - leftCount:SetTextColor(0.5, 0.5, 0.5) - - local leftBg = CreateFrame("Frame", nil, container, "BackdropTemplate") - leftBg:SetPoint("TOPLEFT", 0, -18) - leftBg:SetSize(COL_WIDTH, 240) - GUI:CreateElementBackdrop(leftBg, { bgColor = GUI.Colors.background }) - - local leftScroll = CreateFrame("ScrollFrame", nil, leftBg, "ScrollFrameTemplate") - leftScroll:SetPoint("TOPLEFT", 4, -4) - leftScroll:SetPoint("BOTTOMRIGHT", -24, 4) - - local leftContent = CreateFrame("Frame", nil, leftScroll) - leftContent:SetSize(COL_WIDTH - 28, 1) - leftScroll:SetScrollChild(leftContent) - StyleScrollBar(leftScroll) - - -- ========== RIGHT COLUMN: Pinned Units ========== - local rightHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") - rightHeader:SetPoint("TOPLEFT", leftBg, "TOPRIGHT", COL_GAP, 18) - rightHeader:SetText(L["Pinned Units"]) - rightHeader:SetTextColor(0.7, 0.7, 0.7) - - local rightCount = container:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") - rightCount:SetPoint("LEFT", rightHeader, "RIGHT", 8, 0) - rightCount:SetTextColor(0.5, 0.5, 0.5) - - local rightBg = CreateFrame("Frame", nil, container, "BackdropTemplate") - rightBg:SetPoint("TOPLEFT", leftBg, "TOPRIGHT", COL_GAP, 0) - rightBg:SetSize(COL_WIDTH, 240) - GUI:CreateElementBackdrop(rightBg, { bgColor = GUI.Colors.background }) - - local rightScroll = CreateFrame("ScrollFrame", nil, rightBg, "ScrollFrameTemplate") - rightScroll:SetPoint("TOPLEFT", 4, -4) - rightScroll:SetPoint("BOTTOMRIGHT", -24, 4) - - local rightContent = CreateFrame("Frame", nil, rightScroll) - rightContent:SetSize(COL_WIDTH - 28, 1) - rightScroll:SetScrollChild(rightContent) - StyleScrollBar(rightScroll) - - -- ========== HELPER FUNCTIONS ========== - - -- Get current group roster - local function GetGroupRoster() - local roster = {} - local numMembers = GetNumGroupMembers() - if numMembers == 0 then - -- Solo - just show player - local name = UnitName("player") - local realm = GetRealmName() - local _, class = UnitClass("player") - table.insert(roster, { - name = name, - fullName = name .. "-" .. realm, - class = class or "WARRIOR", - role = "DAMAGER", - group = 1, - }) - return roster - end - - local isRaid = IsInRaid() - - for i = 1, numMembers do - local unit = isRaid and ("raid" .. i) or (i == 1 and "player" or "party" .. (i - 1)) - local name, realm = UnitName(unit) - - if name then - realm = realm or GetRealmName() - local fullName = name .. "-" .. realm - local _, class = UnitClass(unit) - local role = UnitGroupRolesAssigned(unit) - if role == "NONE" then role = "DAMAGER" end - local group = 1 - if isRaid then - local raidIndex = UnitInRaid(unit) - if raidIndex then - local _, _, subgroup = GetRaidRosterInfo(raidIndex + 1) - group = subgroup or 1 - end - end - - table.insert(roster, { - name = name, - fullName = fullName, - class = class or "WARRIOR", - role = role or "DAMAGER", - group = group, - }) - end - end - - -- Sort by group, then role, then name - table.sort(roster, function(a, b) - if a.group ~= b.group then return a.group < b.group end - local roleOrder = { TANK = 1, HEALER = 2, DAMAGER = 3 } - local aRole = roleOrder[a.role] or 3 - local bRole = roleOrder[b.role] or 3 - if aRole ~= bRole then return aRole < bRole end - return a.name < b.name - end) - - return roster - end - - -- Check if player is in highlighted list - local function IsPlayerHighlighted(fullName) - local players = getPlayersFunc() - for _, p in ipairs(players) do - if p == fullName then return true end - end - return false - end - - -- Check if player is in current group - local function IsPlayerInGroup(fullName) - for _, p in ipairs(currentRoster) do - if p.fullName == fullName or p.name == fullName then - return true, p - end - end - return false, nil - end - - -- Add player to highlight list - local function AddPlayer(fullName) - local players = getPlayersFunc() - if not IsPlayerHighlighted(fullName) then - table.insert(players, fullName) - setPlayersFunc(players) - if onChangeCallback then onChangeCallback() end - end - end - - -- Remove player from highlight list - local function RemovePlayer(fullName) - local players = getPlayersFunc() - for i, p in ipairs(players) do - if p == fullName then - table.remove(players, i) - setPlayersFunc(players) - if onChangeCallback then onChangeCallback() end - break - end - end - end - - -- Create grip texture - local function CreateGripTexture(parentFrame) - local grip = CreateFrame("Frame", nil, parentFrame) - grip:SetSize(12, 14) - - local icon = grip:CreateTexture(nil, "ARTWORK") - icon:SetAllPoints(grip) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\reorder") - icon:SetVertexColor(0.5, 0.5, 0.5, 1) - grip.icon = icon - - grip.SetGripColor = function(self, r, g, b) - self.icon:SetVertexColor(r, g, b, 1) - end - - return grip - end - - -- Create role icon using custom textures - local function CreateRoleIcon(parentFrame, role) - local icon = parentFrame:CreateTexture(nil, "ARTWORK") - icon:SetSize(14, 14) - icon:SetTexture(ROLE_ICONS[role] or ROLE_ICONS.DAMAGER) - return icon - end - - -- ========== ROSTER ITEM (Left Column) ========== - local function CreateRosterItem(playerData, index) - local item = CreateFrame("Frame", nil, leftContent, "BackdropTemplate") - item:SetHeight(ITEM_HEIGHT - ITEM_GAP) - item:SetPoint("TOPLEFT", 0, -((index - 1) * ITEM_HEIGHT)) - item:SetPoint("TOPRIGHT", 0, -((index - 1) * ITEM_HEIGHT)) - -- Transparent plate: the hover/selected states tint it, so it needs a fill - -- to colour but no outline of its own. - CreateElementBackdrop(item, { outline = false, bgColor = { 0, 0, 0, 0 } }) - - item.playerData = playerData - - -- Role icon - local roleIcon = CreateRoleIcon(item, playerData.role) - roleIcon:SetPoint("LEFT", 4, 0) - item.roleIcon = roleIcon - - -- Name (class colored) - local nameText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - nameText:SetPoint("LEFT", roleIcon, "RIGHT", 6, 0) - nameText:SetPoint("RIGHT", -70, 0) - nameText:SetJustifyH("LEFT") - nameText:SetText(playerData.name) - local classColor = DF:GetClassColor(playerData.class) - if classColor then - nameText:SetTextColor(classColor.r, classColor.g, classColor.b) - else - nameText:SetTextColor(0.8, 0.8, 0.8) - end - item.nameText = nameText - - -- Group number - local groupText = item:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") - groupText:SetPoint("RIGHT", -34, 0) - groupText:SetText("G" .. playerData.group) - groupText:SetTextColor(0.4, 0.4, 0.4) - item.groupText = groupText - - -- Add button - local addBtn = CreateFrame("Button", nil, item, "BackdropTemplate") - addBtn:SetSize(26, 20) - addBtn:SetPoint("RIGHT", -4, 0) - -- UpdateAddButton (called below, and on every state change) owns both - -- colours, so this only supplies the chrome. - CreateElementBackdrop(addBtn) - - local themeColor = GetThemeColor() - - -- Icon for button - addBtn.icon = addBtn:CreateTexture(nil, "OVERLAY") - addBtn.icon:SetSize(12, 12) - addBtn.icon:SetPoint("CENTER", 0, 0) - - local function UpdateAddButton() - local isHighlighted = IsPlayerHighlighted(playerData.fullName) - if isHighlighted then - addBtn:SetBackdropColor(0.15, 0.15, 0.15, 0.8) - addBtn:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5) - addBtn.icon:SetTexture(ICON_CHECK) - addBtn.icon:SetVertexColor(0.4, 0.4, 0.4) - item:SetBackdropColor(0.1, 0.1, 0.1, 0.5) - nameText:SetAlpha(0.5) - groupText:SetAlpha(0.5) - roleIcon:SetAlpha(0.5) - else - addBtn:SetBackdropColor(themeColor.r * 0.2, themeColor.g * 0.2, themeColor.b * 0.2, 0.8) - addBtn:SetBackdropBorderColor(themeColor.r * 0.5, themeColor.g * 0.5, themeColor.b * 0.5, 0.8) - addBtn.icon:SetTexture(ICON_ARROW) - addBtn.icon:SetVertexColor(themeColor.r, themeColor.g, themeColor.b) - item:SetBackdropColor(0, 0, 0, 0) - nameText:SetAlpha(1) - groupText:SetAlpha(1) - roleIcon:SetAlpha(1) - end - end - - addBtn:SetScript("OnClick", function() - if not IsPlayerHighlighted(playerData.fullName) then - AddPlayer(playerData.fullName) - container:Refresh() - end - end) - - addBtn:SetScript("OnEnter", function(self) - if not IsPlayerHighlighted(playerData.fullName) then - self:SetBackdropColor(themeColor.r * 0.3, themeColor.g * 0.3, themeColor.b * 0.3, 1) - self:SetBackdropBorderColor(themeColor.r, themeColor.g, themeColor.b, 1) - end - end) - - addBtn:SetScript("OnLeave", function(self) - UpdateAddButton() - end) - - item.addBtn = addBtn - item.UpdateAddButton = UpdateAddButton - UpdateAddButton() - - return item - end - - -- ========== HIGHLIGHT ITEM (Right Column - Draggable) ========== - local function CreateHighlightItem(fullName, index, totalCount) - local item = CreateFrame("Frame", nil, rightContent, "BackdropTemplate") - item:SetHeight(ITEM_HEIGHT - ITEM_GAP) - item:SetPoint("TOPLEFT", 0, -((index - 1) * ITEM_HEIGHT)) - item:SetPoint("TOPRIGHT", 0, -((index - 1) * ITEM_HEIGHT)) - item:EnableMouse(true) - CreateElementBackdrop(item, { - bgColor = { 0.12, 0.12, 0.12, 0.9 }, - borderColor = { 0.25, 0.25, 0.25, 1 }, - }) - - item.fullName = fullName - item.index = index - - -- Check if player is in current group - local inGroup, playerData = IsPlayerInGroup(fullName) - - -- Grip handle - local grip = CreateGripTexture(item) - grip:SetPoint("LEFT", 4, 0) - item.grip = grip - - -- Position number - local themeColor = GetThemeColor() - local numText = item:CreateFontString(nil, "OVERLAY", "DFFontNormal") - numText:SetPoint("LEFT", grip, "RIGHT", 6, 0) - numText:SetWidth(20) - numText:SetJustifyH("LEFT") - numText:SetText(index .. ".") - numText:SetTextColor(themeColor.r, themeColor.g, themeColor.b) - item.numText = numText - - -- Role icon - local role = playerData and playerData.role or "DAMAGER" - local roleIcon = CreateRoleIcon(item, role) - roleIcon:SetPoint("LEFT", numText, "RIGHT", 4, 0) - item.roleIcon = roleIcon - - -- Name - local displayName = fullName:match("([^%-]+)") or fullName -- Get name before realm - local nameText = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - nameText:SetPoint("LEFT", roleIcon, "RIGHT", 6, 0) - nameText:SetPoint("RIGHT", -34, 0) - nameText:SetJustifyH("LEFT") - - if playerData then - nameText:SetText(playerData.name) - local classColor = DF:GetClassColor(playerData.class) - if classColor then - nameText:SetTextColor(classColor.r, classColor.g, classColor.b) - end - else - -- Player not in group - nameText:SetText(displayName .. " " .. L["(offline)"]) - nameText:SetTextColor(0.5, 0.5, 0.5) - item:SetBackdropColor(0.1, 0.1, 0.1, 0.7) - grip:SetGripColor(0.35, 0.35, 0.35) - roleIcon:SetAlpha(0.5) - end - item.nameText = nameText - - -- Remove button - local removeBtn = CreateFrame("Button", nil, item, "BackdropTemplate") - removeBtn:SetSize(26, 20) - removeBtn:SetPoint("RIGHT", -4, 0) - CreateElementBackdrop(removeBtn, { - bgColor = { 0.5, 0.15, 0.15, 0.5 }, - borderColor = { 0.6, 0.25, 0.25, 0.8 }, - }) - - -- X icon for remove button - removeBtn.icon = removeBtn:CreateTexture(nil, "OVERLAY") - removeBtn.icon:SetSize(12, 12) - removeBtn.icon:SetPoint("CENTER", 0, 0) - removeBtn.icon:SetTexture(ICON_CLOSE) - removeBtn.icon:SetVertexColor(0.8, 0.3, 0.3) - - removeBtn:SetScript("OnClick", function() - RemovePlayer(fullName) - container:Refresh() - end) - - removeBtn:SetScript("OnEnter", function(self) - self:SetBackdropColor(0.6, 0.2, 0.2, 0.8) - self:SetBackdropBorderColor(0.8, 0.3, 0.3, 1) - end) - - removeBtn:SetScript("OnLeave", function(self) - self:SetBackdropColor(0.5, 0.15, 0.15, 0.5) - self:SetBackdropBorderColor(0.6, 0.25, 0.25, 0.8) - end) - - item.removeBtn = removeBtn - - -- ========== DRAG HANDLERS ========== - item:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - draggingItem = self - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local itemTop = self:GetTop() - dragOffsetY = itemTop - cursorY - - self:SetBackdropColor(0.25, 0.25, 0.4, 0.95) - self:SetBackdropBorderColor(themeColor.r, themeColor.g, themeColor.b, 1) - self:SetFrameLevel(rightContent:GetFrameLevel() + 10) - self.grip:SetGripColor(1, 1, 1) - end - end) - - item:SetScript("OnMouseUp", function(self, button) - if button == "LeftButton" and draggingItem == self then - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local contentTop = rightContent:GetTop() - if contentTop then - local relativeY = contentTop - cursorY - local newIndex = math.floor(relativeY / ITEM_HEIGHT) + 1 - newIndex = math.max(1, math.min(newIndex, totalCount)) - - local oldIndex = self.index - if newIndex ~= oldIndex then - -- Reorder the players array - local players = getPlayersFunc() - local removed = table.remove(players, oldIndex) - table.insert(players, newIndex, removed) - setPlayersFunc(players) - if onChangeCallback then onChangeCallback() end - end - end - - draggingItem = nil - container:Refresh() - end - end) - - item:SetScript("OnUpdate", function(self) - if draggingItem ~= self then return end - - local cursorY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale() - local contentTop = rightContent:GetTop() - local contentBottom = rightContent:GetBottom() - - if not contentTop or not contentBottom then return end - - local targetY = cursorY + dragOffsetY - local offsetFromTop = contentTop - targetY - - local maxOffset = math.max(0, (totalCount - 1) * ITEM_HEIGHT) - offsetFromTop = math.max(0, math.min(offsetFromTop, maxOffset)) - - self:ClearAllPoints() - self:SetPoint("TOPLEFT", rightContent, "TOPLEFT", 0, -offsetFromTop) - self:SetPoint("TOPRIGHT", rightContent, "TOPRIGHT", 0, -offsetFromTop) - - -- Update visual positions of other items - local dropIndex = math.floor(offsetFromTop / ITEM_HEIGHT) + 1 - dropIndex = math.max(1, math.min(dropIndex, totalCount)) - - for _, otherItem in ipairs(highlightItems) do - if otherItem ~= self then - local visualIndex = otherItem.index - if self.index < dropIndex then - -- Dragging down - if otherItem.index > self.index and otherItem.index <= dropIndex then - visualIndex = otherItem.index - 1 - end - else - -- Dragging up - if otherItem.index < self.index and otherItem.index >= dropIndex then - visualIndex = otherItem.index + 1 - end - end - otherItem:ClearAllPoints() - otherItem:SetPoint("TOPLEFT", rightContent, "TOPLEFT", 0, -((visualIndex - 1) * ITEM_HEIGHT)) - otherItem:SetPoint("TOPRIGHT", rightContent, "TOPRIGHT", 0, -((visualIndex - 1) * ITEM_HEIGHT)) - otherItem.numText:SetText(visualIndex .. ".") - end - end - end) - - -- Hover effects - item:SetScript("OnEnter", function(self) - if draggingItem ~= self then - self:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) - self.grip:SetGripColor(0.8, 0.8, 0.8) - end - end) - - item:SetScript("OnLeave", function(self) - if draggingItem ~= self then - self:SetBackdropBorderColor(0.25, 0.25, 0.25, 1) - if inGroup then - self.grip:SetGripColor(0.5, 0.5, 0.5) - else - self.grip:SetGripColor(0.35, 0.35, 0.35) - end - end - end) - - return item - end - - -- ========== QUICK ADD BUTTONS ========== - local buttonRow = CreateFrame("Frame", nil, container) - buttonRow:SetSize(460, 28) - buttonRow:SetPoint("TOPLEFT", leftBg, "BOTTOMLEFT", 0, -8) - - local function CreateQuickAddButton(text, role, color, xOffset) - local btn = CreateFrame("Button", nil, buttonRow, "BackdropTemplate") - btn:SetPoint("LEFT", xOffset, 0) - -- Persistent role colour via the shared tinted variant — the colour IS the - -- button's identity, so it stays on at rest and brightens on hover. - GUI:StyleButton(btn, { - width = 68, height = 24, - tinted = true, - accent = { r = color[1], g = color[2], b = color[3] }, - text = text, - }) - btn:SetScript("OnClick", function() - local players = getPlayersFunc() - for _, player in ipairs(currentRoster) do - if role == "ALL" or player.role == role then - if not IsPlayerHighlighted(player.fullName) then - table.insert(players, player.fullName) - end - end - end - setPlayersFunc(players) - if onChangeCallback then onChangeCallback() end - container:Refresh() - end) - return btn - end - - CreateQuickAddButton("+ " .. L["Tanks"], "TANK", ROLE_COLORS.TANK, 0) - CreateQuickAddButton("+ " .. L["Healers"], "HEALER", ROLE_COLORS.HEALER, 72) - CreateQuickAddButton("+ " .. L["DPS"], "DAMAGER", ROLE_COLORS.DAMAGER, 144) - CreateQuickAddButton("+ " .. L["All"], "ALL", {0.6, 0.6, 0.6}, 216) - - -- Clear All button (right side) — persistent red via the tinted variant. - local clearBtn = CreateFrame("Button", nil, buttonRow, "BackdropTemplate") - clearBtn:SetPoint("RIGHT", 0, 0) - GUI:StyleButton(clearBtn, { - width = 68, height = 24, - tinted = true, - accent = { r = 0.85, g = 0.35, b = 0.35 }, - text = L["Clear All"], - }) - clearBtn:SetScript("OnClick", function() - setPlayersFunc({}) - if onChangeCallback then onChangeCallback() end - container:Refresh() - end) - - -- Remove Offline button (next to Clear All) — persistent gold via tinted. - local removeOfflineBtn = CreateFrame("Button", nil, buttonRow, "BackdropTemplate") - removeOfflineBtn:SetPoint("RIGHT", clearBtn, "LEFT", -6, 0) - GUI:StyleButton(removeOfflineBtn, { - width = 90, height = 24, - tinted = true, - accent = { r = 0.85, g = 0.65, b = 0.35 }, - text = L["Remove Offline"], - }) - removeOfflineBtn:SetScript("OnClick", function() - local players = getPlayersFunc() - local newPlayers = {} - - -- Keep only players that are in the current roster - for _, fullName in ipairs(players) do - local inGroup = false - for _, p in ipairs(currentRoster) do - if p.fullName == fullName or p.name == fullName then - inGroup = true - break - end - end - if inGroup then - table.insert(newPlayers, fullName) - end - end - - setPlayersFunc(newPlayers) - if onChangeCallback then onChangeCallback() end - container:Refresh() - end) - - -- ========== MANUAL PLAYER ENTRY ========== - local themeColor = GetThemeColor() - local manualHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") - manualHeader:SetPoint("TOPLEFT", buttonRow, "BOTTOMLEFT", 0, -12) - manualHeader:SetText(L["Add Offline Player"]) - manualHeader:SetTextColor(themeColor.r, themeColor.g, themeColor.b) - - local manualHelp = container:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") - manualHelp:SetPoint("TOPLEFT", manualHeader, "BOTTOMLEFT", 0, -2) - manualHelp:SetText(L["Pre-configure players before they join the group"]) - manualHelp:SetTextColor(0.45, 0.45, 0.45) - - local manualInput = CreateFrame("EditBox", nil, container, "BackdropTemplate") - manualInput:SetPoint("TOPLEFT", manualHelp, "BOTTOMLEFT", 0, -6) - manualInput:SetSize(380, 24) - GUI:StyleEditBox(manualInput, { skipFont = true }) - manualInput:SetFontObject(DFFontHighlight) - manualInput:SetTextInsets(8, 8, 0, 0) - manualInput:SetAutoFocus(false) - manualInput:SetMaxLetters(50) - - manualInput:SetScript("OnEscapePressed", function(self) - self:ClearFocus() - end) - - manualInput:SetScript("OnEnterPressed", function(self) - local text = self:GetText():trim() - if text ~= "" then - -- Add realm if not present - if not text:find("-") then - text = text .. "-" .. GetRealmName() - end - AddPlayer(text) - self:SetText("") - container:Refresh() - end - self:ClearFocus() - end) - - local addManualBtn = CreateFrame("Button", nil, container, "BackdropTemplate") - addManualBtn:SetPoint("LEFT", manualInput, "RIGHT", 6, 0) - GUI:StyleButton(addManualBtn, { - width = 54, height = 24, - tinted = true, - text = L["Add"], - }) - addManualBtn:SetScript("OnClick", function() - local text = manualInput:GetText():trim() - if text ~= "" then - if not text:find("-") then - text = text .. "-" .. GetRealmName() - end - AddPlayer(text) - manualInput:SetText("") - container:Refresh() - end - end) - - -- ========== REFRESH FUNCTION ========== - function container:Refresh() - -- Get current roster - currentRoster = GetGroupRoster() - local players = getPlayersFunc() - - -- Clear existing items - for _, item in ipairs(rosterItems) do - item:Hide() - item:SetParent(nil) - end - wipe(rosterItems) - - for _, item in ipairs(highlightItems) do - item:Hide() - item:SetParent(nil) - end - wipe(highlightItems) - - -- Update counts - leftCount:SetText("(" .. #currentRoster .. ")") - rightCount:SetText("(" .. #players .. ")") - - -- Build left column (roster) - for i, playerData in ipairs(currentRoster) do - local item = CreateRosterItem(playerData, i) - table.insert(rosterItems, item) - end - leftContent:SetHeight(math.max(1, #currentRoster * ITEM_HEIGHT)) - - -- Build right column (highlighted) - for i, fullName in ipairs(players) do - local item = CreateHighlightItem(fullName, i, #players) - table.insert(highlightItems, item) - end - rightContent:SetHeight(math.max(1, #players * ITEM_HEIGHT)) - - -- Show hint if empty - if #players == 0 then - if not container.emptyHint then - container.emptyHint = rightContent:CreateFontString(nil, "OVERLAY", "DFFontNormal") - container.emptyHint:SetPoint("CENTER", rightBg, "CENTER", 0, 0) - container.emptyHint:SetText(L["Add players from the roster\nor use quick add buttons"]) - container.emptyHint:SetTextColor(0.35, 0.35, 0.35) - container.emptyHint:SetJustifyH("CENTER") - end - container.emptyHint:Show() - elseif container.emptyHint then - container.emptyHint:Hide() - end - end - - -- Register for roster updates - container:RegisterEvent("GROUP_ROSTER_UPDATE") - container:RegisterEvent("PLAYER_ENTERING_WORLD") - container:SetScript("OnEvent", function(self, event) - self:Refresh() - end) - - -- Initial refresh - container:Refresh() - - return container -end - --- Gradient Preview Bar -function GUI:CreateGradientBar(parent, width, height, db, prefix) - prefix = prefix or "healthColor" - local f = CreateFrame("Frame", nil, parent, "BackdropTemplate") - f:SetSize(width or 360, height or 24) - CreateElementBackdrop(f) - - local lbl = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmallOutline") - lbl:SetPoint("LEFT", f, "LEFT", 8, 0) - lbl:SetText("0%") - lbl:SetTextColor(1, 1, 1, 1) - - local lbl2 = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmallOutline") - lbl2:SetPoint("RIGHT", f, "RIGHT", -8, 0) - lbl2:SetText("100%") - lbl2:SetTextColor(1, 1, 1, 1) - - f.TexPool = {} - - f.UpdatePreview = function() - if not db then return end - - for _, tex in ipairs(f.TexPool) do tex:Hide() end - - local _, pClass = UnitClass("player") - local classCol = DF:GetClassColor(pClass) - - local function GetC(stage) - if db[prefix .. stage .. "UseClass"] then - return CreateColor(classCol.r, classCol.g, classCol.b, 1) - end - local c = db[prefix .. stage] - if not c or not c.r then return CreateColor(1, 1, 1, 1) end - return CreateColor(c.r, c.g, c.b, 1) - end - - local lCol = GetC("Low") - local mCol = GetC("Medium") - local hCol = GetC("High") - - local lowW = math.max(1, math.floor(db[prefix .. "LowWeight"] or 1)) - local medW = math.max(1, math.floor(db[prefix .. "MediumWeight"] or 1)) - local highW = math.max(1, math.floor(db[prefix .. "HighWeight"] or 1)) - - local points = {} - for i = 1, lowW do table.insert(points, lCol) end - for i = 1, medW do table.insert(points, mCol) end - for i = 1, highW do table.insert(points, hCol) end - - if #points < 2 then points = {lCol, hCol} end - - local numSegments = #points - 1 - local segWidth = (f:GetWidth() - 4) / numSegments - - for i = 1, numSegments do - local tex = f.TexPool[i] - if not tex then - tex = f:CreateTexture(nil, "ARTWORK") - table.insert(f.TexPool, tex) - end - - tex:Show() - tex:ClearAllPoints() - tex:SetPoint("LEFT", f, "LEFT", 2 + (i - 1) * segWidth, 0) - tex:SetSize(segWidth, f:GetHeight() - 4) - - local c1 = points[i] - local c2 = points[i + 1] - - tex:SetColorTexture(1, 1, 1, 1) - tex:SetGradient("HORIZONTAL", c1, c2) - end - end - - f:SetScript("OnShow", f.UpdatePreview) - f.UpdatePreview() - return f -end - --- ========================================================================= --- SELECTABLE LIST WIDGET --- Scrollable list of selectable items with hover highlight and accent --- selection bar. Used by the Wizard Builder for wizard/step lists. --- ========================================================================= - --- ========================================================================= --- SEARCHABLE DROPDOWN WIDGET --- Dropdown with a search/filter box. Used for the DB key picker (800+ keys) --- and any large option set. Groups items by category headers. --- ========================================================================= - --- ========================================================================= --- KEY-VALUE EDITOR WIDGET --- Editable list of key=value rows for the wizard builder settings map. --- Each row: [Searchable Key Dropdown] = [Value Input] [X Delete] --- ========================================================================= - --- ========================================================================= --- BRANCH EDITOR WIDGET --- Visual editor for conditional wizard branching rules. --- Each row: IF [step] [operator] [value] → [goto step] [X] --- Plus: ELSE → [fallback step] --- ========================================================================= - --- ========================================================================= --- MAIN GUI CREATION --- ========================================================================= - -function DF:ToggleGUI() - if DF.GUIFrame and DF.GUIFrame:IsShown() then - DF.GUIFrame:Hide() - else - if not DF.GUIFrame then - DF:CreateGUI() - end - - -- Auto-detect mode based on current group status - -- ARENA FIX: Arena returns IsInRaid()=true but uses party-style layout/settings. - -- Check for arena first so the settings UI shows party settings, not raid. - if DF.IsInArena and DF:IsInArena() then - GUI.SelectedMode = "party" - elseif IsInRaid() then - GUI.SelectedMode = "raid" - else - GUI.SelectedMode = "party" - end - - -- Update theme colors to match selected mode - if GUI.UpdateThemeColors then - GUI.UpdateThemeColors() - end - - -- Show correct content for the selected mode - if GUI.ShowNormalContent then - GUI:ShowNormalContent() - end - - -- Refresh editing UI state (re-enables tabs that were disabled when closed during editing) - local AutoProfilesUI = DF.AutoProfilesUI - if AutoProfilesUI and AutoProfilesUI.RefreshEditingUI then - AutoProfilesUI:RefreshEditingUI() - end - - -- Refresh override stars (shows if a runtime profile is active) - if AutoProfilesUI and AutoProfilesUI.RefreshTabOverrideStars then - AutoProfilesUI:RefreshTabOverrideStars() - end - - DF.GUIFrame:Show() - GUI:RefreshCurrentPage() - - -- Auto-show changelog on first open after update - if DandersFramesDB_v2 and DandersFramesDB_v2.lastSeenVersion ~= DF.VERSION then - DandersFramesDB_v2.lastSeenVersion = DF.VERSION - if GUI.changelogOverlay and GUI.changelogArea then - GUI.changelogArea:SetText(GUI.FormatChangelog(DF.CHANGELOG_TEXT)) - GUI.changelogOverlay:Show() - end - end - end -end - -function DF:CreateGUI() - if DF.GUIFrame then return end - - -- Default and saved sizes - local defaultWidth, defaultHeight = 760, 520 - local minWidth, minHeight = 520, 400 - local maxWidth, maxHeight = 1200, 900 - - -- Load saved position and size (stored in party db since it's always available) - local guiDb = DF.db and DF.db.party or {} - local savedScale = guiDb.guiScale or 1.0 - local savedWidth = guiDb.guiWidth or defaultWidth - local savedHeight = guiDb.guiHeight or defaultHeight - - -- Main frame (matching old addon approach - no BackdropTemplate in CreateFrame) - local frame = CreateFrame("Frame", "DandersFramesGUI", UIParent) - frame:SetSize(savedWidth, savedHeight) - -- Restore saved position, or default to center - if guiDb.guiPoint and guiDb.guiX then - frame:SetPoint(guiDb.guiPoint, UIParent, guiDb.guiRelPoint or "CENTER", guiDb.guiX, guiDb.guiY) - else - frame:SetPoint("CENTER") - end - frame:SetFrameStrata("DIALOG") -- Match old addon - frame:SetToplevel(true) -- Match old addon - frame:SetMovable(true) - frame:SetResizable(true) - frame:SetResizeBounds(minWidth, minHeight, maxWidth, maxHeight) - frame:EnableMouse(true) - frame:SetScale(savedScale) - -- Note: Dragging is handled by titleBar, not main frame - CreatePanelBackdrop(frame) - frame:Hide() - DF.GUIFrame = frame - - -- Allow closing with Escape key - tinsert(UISpecialFrames, "DandersFramesGUI") - - -- Exit profile editing when GUI is closed - frame:SetScript("OnHide", function() - local AutoProfilesUI = DF.AutoProfilesUI - if AutoProfilesUI and AutoProfilesUI:IsEditing() then - AutoProfilesUI:ExitEditing(true) -- Skip UI updates since GUI is closing - end - end) - - -- Title bar (handles dragging like old addon) - -- Uses FULLSCREEN_DIALOG strata so it stays above dropdown menus and popups, - -- allowing the window to be dragged even when settings panels are open. - local titleBar = CreateFrame("Frame", nil, frame) - titleBar:SetHeight(30) - titleBar:SetPoint("TOPLEFT", 0, 0) - titleBar:SetPoint("TOPRIGHT", -30, 0) - titleBar:EnableMouse(true) - titleBar:RegisterForDrag("LeftButton") - titleBar:SetScript("OnDragStart", function() frame:StartMoving() end) - titleBar:SetScript("OnDragStop", function() - frame:StopMovingOrSizing() - -- Save position so it persists across sessions - local point, _, relPoint, x, y = frame:GetPoint() - if DF.db and DF.db.party then - DF.db.party.guiPoint = point - DF.db.party.guiRelPoint = relPoint - DF.db.party.guiX = x - DF.db.party.guiY = y - end - end) - titleBar:SetFrameStrata("FULLSCREEN_DIALOG") - titleBar:SetFrameLevel(200) - - local title = titleBar:CreateFontString(nil, "OVERLAY", "DFFontNormal") - title:SetPoint("LEFT", 12, 0) - local versionStr = DF.VERSION or "Unknown" - local channelTags = { alpha = " |cffff8800alpha|r", beta = " |cffff8800beta|r" } - local channelTag = channelTags[DF.RELEASE_CHANNEL] or "" - title:SetText("DandersFrames " .. versionStr .. channelTag) - local c = GetThemeColor() - title:SetTextColor(c.r, c.g, c.b) - title.UpdateTheme = function() - local nc = GetThemeColor() - title:SetTextColor(nc.r, nc.g, nc.b) - end - - -- Close button with icon - local closeBtn = GUI:CreateCloseButton(frame, { size = 20, onClick = function() frame:Hide() end }) - closeBtn:SetPoint("TOPRIGHT", -8, -5) - closeBtn:SetFrameStrata("FULLSCREEN_DIALOG") - closeBtn:SetFrameLevel(210) - - -- Info button (changelog) - local infoBtn = CreateFrame("Button", nil, frame, "BackdropTemplate") - infoBtn:SetPoint("TOPRIGHT", -32, -5) - infoBtn:SetFrameStrata("FULLSCREEN_DIALOG") - infoBtn:SetFrameLevel(210) - -- Icon-only changelog button via the shared styler (backdrop + hover); the hook - -- brightens the icon to the theme colour on hover. - GUI:StyleButton(infoBtn, { - width = 20, height = 20, - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\notes", size = 16, color = C_TEXT_DIM }, - }) - infoBtn:HookScript("OnEnter", function(self) - local tc = GetThemeColor() - self.Icon:SetVertexColor(tc.r, tc.g, tc.b) - end) - infoBtn:HookScript("OnLeave", function(self) - self.Icon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end) - - -- Changelog overlay (covers full content area below title bar) - local changelogOverlay = CreateFrame("Frame", nil, frame, "BackdropTemplate") - changelogOverlay:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, -30) - changelogOverlay:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - changelogOverlay:SetFrameStrata("FULLSCREEN_DIALOG") - changelogOverlay:SetFrameLevel(300) - CreatePanelBackdrop(changelogOverlay) - changelogOverlay:Hide() - GUI.changelogOverlay = changelogOverlay - - -- Header bar within the overlay - local changelogHeader = CreateFrame("Frame", nil, changelogOverlay) - changelogHeader:SetPoint("TOPLEFT", 8, -8) - changelogHeader:SetPoint("TOPRIGHT", -8, -8) - changelogHeader:SetHeight(24) - - local changelogTitle = changelogHeader:CreateFontString(nil, "OVERLAY", "DFFontNormal") - changelogTitle:SetPoint("LEFT", 4, 0) - changelogTitle:SetText(L["Changelog"] .. " — " .. versionStr) - changelogTitle:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local backBtn = CreateFrame("Button", nil, changelogHeader, "BackdropTemplate") - backBtn:SetPoint("RIGHT", 0, 0) - GUI:StyleButton(backBtn, { width = 60, height = 22, text = L["Close"] }) - backBtn:SetScript("OnClick", function() changelogOverlay:Hide() end) - - -- Convert markdown changelog to WoW color-coded plain text - local function FormatChangelog(text) - if not text or text == "" then return L["No changelog available."] end - local tc = GetThemeColor() - local themeHex = format("%02x%02x%02x", tc.r * 255, tc.g * 255, tc.b * 255) - local dimHex = format("%02x%02x%02x", C_TEXT_DIM.r * 255, C_TEXT_DIM.g * 255, C_TEXT_DIM.b * 255) - local textHex = format("%02x%02x%02x", C_TEXT.r * 255, C_TEXT.g * 255, C_TEXT.b * 255) - - local lines = {} - for line in text:gmatch("[^\n]*") do - if line:match("^# ") then - -- Main title — skip (already shown in header bar) - elseif line:match("^## ") then - -- Version header - local content = line:gsub("^##%s*", "") - lines[#lines + 1] = format("|cff%s%s|r", themeHex, content) - elseif line:match("^### ") then - -- Section header - local content = line:gsub("^###%s*", "") - lines[#lines + 1] = format("\n|cff%s%s|r", textHex, content) - elseif line:match("^%*%s") or line:match("^%-%s") then - -- Bullet point - local content = line:gsub("^[%*%-]%s*", "") - lines[#lines + 1] = format(" |cff%s\226\128\162|r |cff%s%s|r", themeHex, dimHex, content) - elseif line:match("^%s*$") then - lines[#lines + 1] = "" - else - lines[#lines + 1] = format("|cff%s%s|r", dimHex, line) - end - end - - return table.concat(lines, "\n") - end - - -- plain: the overlay already IS the panel, so the text area contributes only - -- the scrolling field. readOnly rather than EnableKeyboard(false) — the - -- changelog stays uneditable but becomes selectable and copyable. - local changelogArea = GUI:CreateTextArea(changelogOverlay, { - plain = true, - readOnly = true, - text = FormatChangelog(DF.CHANGELOG_TEXT), - }) - changelogArea:SetPoint("TOPLEFT", 8, -38) - changelogArea:SetPoint("BOTTOMRIGHT", -8, 8) - GUI.FormatChangelog = FormatChangelog - GUI.changelogArea = changelogArea -- .EditBox for the field itself - - infoBtn:SetScript("OnClick", function() - if changelogOverlay:IsShown() then - changelogOverlay:Hide() - else - -- No width fix-up needed: the text area re-syncs its own scroll child. - changelogArea:SetText(FormatChangelog(DF.CHANGELOG_TEXT)) - changelogOverlay:Show() - end - end) - - -- ========================================================================= - -- RESIZE HANDLE (bottom-right corner) - -- ========================================================================= - local resizeHandle = CreateFrame("Button", nil, frame) - resizeHandle:SetSize(16, 16) - resizeHandle:SetPoint("BOTTOMRIGHT", -2, 2) - resizeHandle:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") - resizeHandle:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight") - resizeHandle:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down") - resizeHandle:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - frame:StartSizing("BOTTOMRIGHT") - end - end) - resizeHandle:SetScript("OnMouseUp", function(self, button) - frame:StopMovingOrSizing() - -- Save new size - DF.db.party.guiWidth = frame:GetWidth() - DF.db.party.guiHeight = frame:GetHeight() - -- Update content layout - if GUI.SelectedMode == "clicks" then - -- Refresh click casting UI on resize (skip scroll reset) - if DF.ClickCast and DF.ClickCast.RefreshSpellGrid then - DF.ClickCast:RefreshSpellGrid(true) - end - elseif GUI.RefreshCurrentPage then - GUI:RefreshCurrentPage() - end - end) - - -- Party/Raid mode toggle buttons - local btnParty = CreateFrame("Button", nil, frame, "BackdropTemplate") - -- Head of the toolbar chain (Party <- Raid <- Clicks <- Test <- Unlock), so - -- this offset is what every button along it inherits. - btnParty:SetPoint("TOPLEFT", SnapLen(frame, 12), SnapLen(frame, -32)) - -- Shared underline-tab style; SetActive (in UpdateThemeColors) drives it. - GUI:StyleButton(btnParty, { tab = true, text = L["PARTY"], accent = C_ACCENT, width = 70, height = 24, font = "DFFontHighlight" }) - GUI.PartyButton = btnParty -- Store for external access - - local btnRaid = CreateFrame("Button", nil, frame, "BackdropTemplate") - btnRaid:SetPoint("LEFT", btnParty, "RIGHT", SnapLen(btnRaid, 4), 0) - GUI:StyleButton(btnRaid, { tab = true, text = L["RAID"], accent = C_RAID, width = 70, height = 24, font = "DFFontHighlight" }) - GUI.RaidButton = btnRaid -- Store for external access - - -- Click Casting tab button - local btnClicks = CreateFrame("Button", nil, frame, "BackdropTemplate") - btnClicks:SetPoint("LEFT", btnRaid, "RIGHT", SnapLen(btnClicks, 4), 0) - GUI:StyleButton(btnClicks, { tab = true, text = L["BINDS"], accent = { r = 0.2, g = 0.8, b = 0.4 }, width = 70, height = 24, font = "DFFontHighlight" }) - GUI.ClicksButton = btnClicks - - -- ========================================================================= - -- TEST MODE BUTTON (next to CLICKS tab) - -- ========================================================================= - local btnTest = CreateFrame("Button", nil, frame, "BackdropTemplate") - -- Snapped gap + snapped width below: the toolbar is a CHAIN (Clicks <- Test - -- <- Unlock <- override marker) and controls are not position-corrected after - -- the fact, so every offset and width in the chain has to be a whole number - -- of device pixels or the fraction accumulates rightwards. - btnTest:SetPoint("LEFT", btnClicks, "RIGHT", SnapLen(btnTest, 12), 0) - GUI:StyleButton(btnTest, { - width = 75, height = 24, - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\preview_off", size = 18, color = C_TEXT_DIM }, - text = L["Test"], - }) - GUI:SetSettingsFont(btnTest.Text, 11, "") -- 11px (between Small 10 and Highlight 12) - btnTest.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - -- Content-fit width (less dead space) - btnTest:SetWidth(SnapLenUp(btnTest, math.ceil(btnTest.Text:GetStringWidth()) + 38)) - GUI.TestButton = btnTest - - -- ========================================================================= - -- LOCK/UNLOCK BUTTON (next to Test button) - -- ========================================================================= - local btnLock = CreateFrame("Button", nil, frame, "BackdropTemplate") - btnLock:SetPoint("LEFT", btnTest, "RIGHT", SnapLen(btnLock, 4), 0) - GUI:StyleButton(btnLock, { - width = 80, height = 24, - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\lock", size = 18, color = C_TEXT_DIM }, - text = L["Unlock"], - }) - GUI:SetSettingsFont(btnLock.Text, 11, "") -- 11px (between Small 10 and Highlight 12) - btnLock.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - -- Size to the wider "Unlock" label so toggling Lock/Unlock doesn't resize the - -- button (real label set in UpdateLockButtonState). - btnLock:SetWidth(SnapLenUp(btnLock, math.ceil(btnLock.Text:GetStringWidth()) + 38)) - GUI.LockButton = btnLock - - -- Position override marker (shown next to the lock button when the frame - -- position is overridden in the layout being edited). Shared marker helper → - -- dot + hover tooltip, one colour with every other override marker. - local positionOverrideStar = GUI:CreateOverrideMarker(frame, 14) - positionOverrideStar:SetPoint("LEFT", btnLock, "RIGHT", SnapLen(positionOverrideStar, 4), 0) - positionOverrideStar.tooltipText = L["Override active"] - positionOverrideStar.tooltipSubText = L["The frame position is overridden in this layout."] - GUI.PositionOverrideStar = positionOverrideStar - - -- Function to update position override indicator - local function UpdatePositionOverrideIndicator() - -- Debug mode shows indicator - if S.overrideDebugMode then - positionOverrideStar:Show() - return - end - - if GUI.SelectedMode ~= "raid" then - positionOverrideStar:Hide() - return - end - - local AutoProfilesUI = DF.AutoProfilesUI - if not AutoProfilesUI or not AutoProfilesUI:IsEditing() then - positionOverrideStar:Hide() - return - end - - -- Check if position is overridden (either X or Y) - local xOverridden = AutoProfilesUI:IsSettingOverridden("raidAnchorX") - local yOverridden = AutoProfilesUI:IsSettingOverridden("raidAnchorY") - - if xOverridden or yOverridden then - positionOverrideStar:Show() - else - positionOverrideStar:Hide() - end - end - GUI.UpdatePositionOverrideIndicator = UpdatePositionOverrideIndicator - - -- Forward declaration (defined after UpdateThemeColors) - local UpdateTestButtonState - - local function UpdateLockButtonState() - local db = DF.db[GUI.SelectedMode] - -- Raid mode uses raidLocked, party mode uses locked. Use an explicit - -- branch (NOT `a and b or c`) so an unlocked raid (raidLocked=false) - -- doesn't fall through to the party `locked` value and read as locked. - local isLocked - if db then - if GUI.SelectedMode == "raid" then isLocked = db.raidLocked else isLocked = db.locked end - end - - -- While an auto layout drives the raid frames, dragging the base position by - -- accident is the bug we're preventing: disable the toolbar Unlock and steer - -- users to the active layout's own Unlock button (Auto Layouts page). Only the - -- UNLOCK action is blocked (isLocked) — locking from here still works to finish - -- a session. - local layoutActive = (GUI.SelectedMode == "raid") and DF.AutoProfilesUI - and DF.AutoProfilesUI.IsLayoutActive and DF.AutoProfilesUI:IsLayoutActive() - if layoutActive and isLocked then - btnLock.dfDisabled = true - btnLock.Text:SetText(L["Unlock"]) - btnLock.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\lock") - btnLock:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 0.5) - btnLock:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5) - btnLock.Text:SetTextColor(0.4, 0.4, 0.4) - btnLock.Icon:SetVertexColor(0.4, 0.4, 0.4) - UpdatePositionOverrideIndicator() - return - end - btnLock.dfDisabled = false - - btnLock.Text:SetText(isLocked and L["Unlock"] or L["Lock"]) - btnLock.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. (isLocked and "lock" or "lock_open")) - - if not isLocked then - -- Unlocked - active/selected toggle look (white text/icon like the others) - btnLock:SetActive(true) - btnLock.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btnLock.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - -- Locked - normal rest (white text/icon; state shown by the border/fill) - btnLock:SetActive(false) - btnLock.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btnLock.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - - -- Update position override indicator - UpdatePositionOverrideIndicator() - end - GUI.UpdateLockButtonState = UpdateLockButtonState - - -- HookScript (not SetScript) so the disabled tooltip composes with the - -- StyleButton hover instead of clobbering it. - btnLock:HookScript("OnEnter", function(self) - if self.dfDisabled then - local name = DF.AutoProfilesUI and DF.AutoProfilesUI.GetActiveLayoutName - and DF.AutoProfilesUI:GetActiveLayoutName() - GUI:ShowTooltip(self, { - title = L["Locked by Auto Layout"], - tone = "warning", - lines = { format(L["Auto layout \"%s\" is active. Unlock it from the Auto Layouts page to move its frames."], name or "?") }, - }) - end - end) - btnLock:HookScript("OnLeave", function() GUI:HideTooltip() end) - - btnLock:SetScript("OnClick", function() - if btnLock.dfDisabled then - local name = DF.AutoProfilesUI and DF.AutoProfilesUI.GetActiveLayoutName - and DF.AutoProfilesUI:GetActiveLayoutName() - DF:Say(format(L["Auto layout \"%s\" is active. Unlock it from the Auto Layouts page to move its frames."], name or "?")) - return - end - - local db = DF.db[GUI.SelectedMode] - if not db then return end - - -- Check current lock state using the correct key per mode (explicit - -- branch — `a and b or c` would misread an unlocked raid as locked). - local isLocked - if GUI.SelectedMode == "raid" then isLocked = db.raidLocked else isLocked = db.locked end - - if GUI.SelectedMode == "raid" then - if isLocked then - DF:UnlockRaidFrames() - else - DF:LockRaidFrames() - end - else - if isLocked then - DF:UnlockFrames() - else - DF:LockFrames() - end - end - - -- Lock/Unlock functions now call UpdateLockButtonState themselves, - -- but call it here too as a safety net - UpdateLockButtonState() - UpdateTestButtonState() - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - - -- ========================================================================= - -- UI SCALE SLIDER (top right, always visible with larger min frame size) - -- ========================================================================= - local scaleContainer = CreateFrame("Frame", nil, frame) - scaleContainer:SetSize(155, 24) - scaleContainer:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -12, -32) - - local scaleLabel = scaleContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - scaleLabel:SetPoint("LEFT", 0, 0) - scaleLabel:SetText(L["UI Scale:"]) - scaleLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local scaleSlider = CreateFrame("Slider", nil, scaleContainer, "BackdropTemplate") - scaleSlider:SetPoint("LEFT", scaleLabel, "RIGHT", 6, 0) - scaleSlider:SetSize(65, 14) - scaleSlider:SetOrientation("HORIZONTAL") - scaleSlider:SetMinMaxValues(0.6, 1.4) - scaleSlider:SetValueStep(0.05) - scaleSlider:SetObeyStepOnDrag(true) - scaleSlider:SetValue(savedScale) - CreateElementBackdrop(scaleSlider) - - -- Thumb texture - local thumb = scaleSlider:CreateTexture(nil, "OVERLAY") - thumb:SetSize(12, 14) - thumb:SetColorTexture(0.5, 0.5, 0.5, 1) - scaleSlider:SetThumbTexture(thumb) - - local scaleValue = scaleContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - scaleValue:SetPoint("LEFT", scaleSlider, "RIGHT", 4, 0) - scaleValue:SetText(string.format("%.0f%%", savedScale * 100)) - scaleValue:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Only update text while dragging (not main frame scale - that causes cursor drift) - -- But DO update popup panels live - scaleSlider:SetScript("OnValueChanged", function(self, value) - value = math.floor(value * 20 + 0.5) / 20 -- Round to 0.05 - scaleValue:SetText(string.format("%.0f%%", value * 100)) - -- Update popup panels live (they don't cause cursor drift) - if DF.positionPanel then - DF.positionPanel:SetScale(value) - end - if DF.TestPanel then - DF.TestPanel:SetScale(value) - end - end) - - -- Apply scale only on mouse release to avoid cursor drift issues - scaleSlider:SetScript("OnMouseUp", function(self) - local value = math.floor(self:GetValue() * 20 + 0.5) / 20 - frame:SetScale(value) - if DF.db and DF.db.party then - DF.db.party.guiScale = value - end - -- Also update popup panels - if DF.positionPanel then - DF.positionPanel:SetScale(value) - end - if DF.TestPanel then - DF.TestPanel:SetScale(value) - end - -- A new scale changes how many device pixels a UI unit covers, so - -- every border on screen has to be re-derived at the new thickness. - -- This is the ONLY action that does: nothing else in the GUI writes - -- guiScale, and moving or resizing the window leaves it alone. - GUI:RefreshPixelBorders() - end) - - GUI.ScaleSlider = scaleSlider - GUI.ScaleContainer = scaleContainer - -- ========================================================================= - -- END TOP BAR CONTROLS - -- ========================================================================= - - local function UpdateThemeColors() - -- Mode buttons use the shared underline-tab style; SetActive drives the - -- underline + accent label (each button's per-mode accent set at creation). - btnParty:SetActive(GUI.SelectedMode == "party") - btnRaid:SetActive(GUI.SelectedMode == "raid") - btnClicks:SetActive(GUI.SelectedMode == "clicks") - - -- Test button look via the shared toggle styling (matches how the Lock - -- button is refreshed below). The old inline version painted a stray - -- theme-coloured border even at rest. - if UpdateTestButtonState then UpdateTestButtonState() end - - -- Refresh the toolbar buttons' hover wash to the current mode accent. - -- They live on the main frame (not a page child), so the page - -- ThemeListeners loop below never reaches them — without this their hover - -- stays the party colour after switching to raid. (UpdateTestButtonState/ - -- SetActive only fix the resting backdrop, not the HIGHLIGHT wash.) - if btnTest.UpdateTheme then btnTest.UpdateTheme() end - if btnLock.UpdateTheme then btnLock.UpdateTheme() end - -- infoBtn (changelog) also lives on the main frame, not a page child, so - -- the ThemeListeners loop below never reaches it either — refresh its hover - -- wash to the current mode accent here alongside Test/Lock. - if infoBtn and infoBtn.UpdateTheme then infoBtn.UpdateTheme() end - - -- Show/hide Test and Lock buttons based on mode - if GUI.SelectedMode == "clicks" then - btnTest:Hide() - btnLock:Hide() - else - btnTest:Show() - btnLock:Show() - end - - title.UpdateTheme() - - -- Update active tab - local nc = GetThemeColor() - for name, btn in pairs(GUI.Tabs) do - if btn.isActive and not btn.disabled then - btn.accent:SetColorTexture(nc.r, nc.g, nc.b, 1) - btn.Text:SetTextColor(nc.r, nc.g, nc.b) - btn.Text:SetAlpha(1) - elseif btn.disabled then - btn.Text:SetTextColor(0.4, 0.4, 0.4) - btn.Text:SetAlpha(1) - if btn.accent then btn.accent:Hide() end - end - end - - -- Update theme listeners - if GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] then - local page = GUI.Pages[GUI.CurrentPageName] - if page.child and page.child.ThemeListeners then - for _, widget in ipairs(page.child.ThemeListeners) do - if widget.UpdateTheme then widget:UpdateTheme() end - end - end - end - - -- Update test panel if open (but don't trigger circular updates) - if DF.TestPanel and DF.TestPanel:IsShown() then - DF.TestPanel:UpdateStateNoCallback() - end - - -- Update lock button state - UpdateLockButtonState() - end - GUI.UpdateThemeColors = UpdateThemeColors - - -- Function to update test button state (called externally) - UpdateTestButtonState = function() - -- Active toggle look based on whether the test panel is visible. - local testActive = DF.TestPanel and DF.TestPanel:IsShown() - btnTest:SetActive(testActive) - -- Swap the framed-eye glyph: open (preview) when test mode is showing the - -- preview frames, slashed (preview_off) when it's off. - btnTest.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - .. (testActive and "preview" or "preview_off")) - -- White text/icon in both states (state shown by the toggle border/fill). - btnTest.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btnTest.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - GUI.UpdateTestButtonState = UpdateTestButtonState - - -- Test button scripts - btnTest:RegisterForClicks("LeftButtonUp", "RightButtonUp") - btnTest:SetScript("OnClick", function(self, button) - if button == "RightButton" then - -- Quick toggle test mode - DF:ToggleTestMode() - UpdateThemeColors() - else - -- Open/close test panel - DF:ToggleTestPanel() - UpdateTestButtonState() - end - end) - - -- Hover is handled by StyleButton (resets to grey on leave). The old manual - -- OnEnter/OnLeave left a stuck theme-coloured border because its OnLeave reset - -- to themeColor@0.5 rather than the neutral border. - - btnParty:SetScript("OnClick", function() - DF:SyncLinkedSections() - - -- Carry test mode across the mode switch (raid test -> party test) - local carryTest = false - -- Before switching tabs, clean up current mode's test mode and unlock state - if GUI.SelectedMode == "raid" then - -- Lock raid frames if unlocked - local raidDb = DF:GetRaidDB() - if not raidDb.raidLocked then - raidDb.raidLocked = true - if DF.raidContainer then - DF.raidContainer:EnableMouse(false) - DF.raidContainer:SetMovable(false) - end - if DF.LockRaidFrames then DF:LockRaidFrames() end - end - -- Disable raid test mode if active - if DF.raidTestMode then - carryTest = true - -- Hand-over: stay in container/pinned test mode across the swap rather - -- than tearing every aura container down for live data we never show - -- and rebuilding it a moment later. Cleared below. - DF._testModeHandover = true - DF:HideRaidTestFrames(true) -- silent - end - end - - GUI.SelectedMode = "party" - if DF.Search then - DF.Search:InvalidateRegistry() - DF.Search:RefreshIfActive() - end - UpdateThemeColors() - GUI:ShowNormalContent() - GUI:UpdateTabAvailability() - GUI:RefreshCurrentPage() - - -- Keep test mode active when switching modes (just switch which mode it runs in) - if carryTest and DF.ShowTestFrames then - DF:ShowTestFrames(true) -- silent - -- ShowTestFrames (unlike ShowRaidTestFrames) doesn't refresh the GUI, - -- so the test panel's toggle label would stay on "Enable Test Mode". - -- Refresh it now that party test mode is active. - if DF.TestPanel and DF.TestPanel:IsShown() then - DF.TestPanel:UpdateStateNoCallback() - end - end - if carryTest then - -- End the hand-over and settle: ShowTestFrames can bail (combat, party - -- frames disabled), which would otherwise leave the engines parked in - -- test mode with nothing testing. Teardown re-reads the real flags, so - -- it is a no-op when the incoming mode did start. - DF._testModeHandover = nil - if DF.TeardownTestModeEngines then DF:TeardownTestModeEngines() end - end - end) - btnRaid:SetScript("OnClick", function() - DF:SyncLinkedSections() - - -- Carry test mode across the mode switch (party test -> raid test) - local carryTest = false - -- Before switching tabs, clean up current mode's test mode and unlock state - if GUI.SelectedMode == "party" then - -- Lock party frames if unlocked - local partyDb = DF:GetDB() - if not partyDb.locked then - partyDb.locked = true - if DF.partyContainer then - DF.partyContainer:EnableMouse(false) - DF.partyContainer:SetMovable(false) - end - if DF.LockFrames then DF:LockFrames() end - end - -- Disable party test mode if active - if DF.testMode then - carryTest = true - -- Hand-over: see the raid->party handler above. - DF._testModeHandover = true - DF:HideTestFrames(true) -- silent - end - end - - GUI.SelectedMode = "raid" - if DF.Search then - DF.Search:InvalidateRegistry() - DF.Search:RefreshIfActive() - end - UpdateThemeColors() - GUI:ShowNormalContent() - GUI:UpdateTabAvailability() - GUI:RefreshCurrentPage() - - -- Keep test mode active when switching modes (just switch which mode it runs in) - if carryTest and DF.ShowRaidTestFrames then - DF:ShowRaidTestFrames() - end - if carryTest then - -- End the hand-over and settle; see the raid->party handler above. - DF._testModeHandover = nil - if DF.TeardownTestModeEngines then DF:TeardownTestModeEngines() end - end - end) - - -- Click Casting tab click handler - btnClicks:SetScript("OnClick", function() - -- Clean up any test/unlock state from previous mode - if GUI.SelectedMode == "party" then - local partyDb = DF:GetDB() - if partyDb and not partyDb.locked then - partyDb.locked = true - if DF.LockFrames then DF:LockFrames() end - end - if DF.testMode then DF:HideTestFrames(true) end - elseif GUI.SelectedMode == "raid" then - local raidDb = DF:GetRaidDB() - if raidDb and not raidDb.raidLocked then - raidDb.raidLocked = true - if DF.LockRaidFrames then DF:LockRaidFrames() end - end - if DF.raidTestMode then DF:HideRaidTestFrames(true) end - end - - GUI.SelectedMode = "clicks" - if DF.Search then - DF.Search:HideResults() - end - UpdateThemeColors() - GUI:ShowClickCastingContent() - end) - - -- Tab container (left side) - with scrolling - -- ★ Every offset in the nav chain is SNAPPED, for the same structural reason - -- the page viewport is: these frames are anchored by two corners, nothing - -- nudges them afterwards, and the numbers going in are the only - -- lever. Unsnapped, the whole list inherits the fraction -- /df debug navprobe - -- measured every one of 42 rows at top+0.38, which is the 4-unit inset below - -- (5.625 device px at 1.4062 px/unit) propagated down the chain. A row that - -- starts on a fractional device row draws its hover plate's top and bottom - -- edges across two rows each at partial intensity, and its label on a - -- fractional baseline -- which is what is left of the nav ghost now that the - -- dead band between rows is gone. - local tabFrame = CreateFrame("Frame", nil, frame, "BackdropTemplate") - tabFrame:SetPoint("TOPLEFT", SnapLen(frame, 12), SnapLen(frame, -64)) - tabFrame:SetPoint("BOTTOMLEFT", SnapLen(frame, 12), SnapLen(frame, 36)) - tabFrame:SetWidth(SnapLen(frame, 155)) - CreateElementBackdrop(tabFrame) - tabFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.5) - - -- ========================================================================= - -- SEARCH BAR - -- ========================================================================= - local searchBar = nil - local navPad = SnapLen(tabFrame, 4) or 4 - local navRight = SnapLen(tabFrame, -14) or -14 - local tabScrollStartY = -navPad - if DF.Search then - searchBar = DF.Search:CreateSearchBar(tabFrame) - searchBar:SetPoint("TOPLEFT", navPad, -navPad) - searchBar:SetPoint("TOPRIGHT", navRight, -navPad) - tabScrollStartY = SnapLen(tabFrame, -36) or -36 - end - - local tabScroll = CreateFrame("ScrollFrame", nil, tabFrame, "ScrollFrameTemplate") - tabScroll:SetPoint("TOPLEFT", navPad, tabScrollStartY) - tabScroll:SetPoint("BOTTOMRIGHT", navRight, navPad) - - StyleScrollBar(tabScroll) - -- Custom positioning for tab scrollbar - if tabScroll.ScrollBar then - tabScroll.ScrollBar:ClearAllPoints() - tabScroll.ScrollBar:SetPoint("TOPRIGHT", tabFrame, "TOPRIGHT", -navPad, tabScrollStartY) - tabScroll.ScrollBar:SetPoint("BOTTOMRIGHT", tabFrame, "BOTTOMRIGHT", -navPad, navPad) - end - - local tabContainer = CreateFrame("Frame", nil, tabScroll) - tabContainer:SetWidth(SnapLen(tabScroll, 130) or 130) - tabContainer:SetHeight(600) -- Will be updated dynamically - tabScroll:SetScrollChild(tabContainer) - GUI.tabContainer = tabContainer - GUI.tabScroll = tabScroll - - -- ONE hover plate for the whole nav, MOVED to the row under the cursor -- - -- rather than 42 plates that each switch themselves off and on. - -- - -- Why, after the geometry was already proven clean: navprobe showed no dead - -- band, no overlap, no stale plate, no focus thrash, and every row at - -- top-0.00. Two facts then rule out layout entirely -- the ghost is on LIVE - -- too, which shares none of this branch's GUI work, and its severity varies - -- across identical crossings. Geometry is deterministic; the same path would - -- give the same result every time. Something that varies run to run is - -- timing: the frame in which one row's colour change lands relative to the - -- next row's, which Lua cannot observe and cannot control. - -- - -- So stop relying on the two changes landing in the same frame. With a single - -- plate there is no pair to synchronise: the texture is already on screen and - -- only its anchors move, so no frame can ever show two plates or none. - local navHover = CreateFrame("Frame", nil, tabContainer, "BackdropTemplate") - navHover:EnableMouse(false) -- must never take focus from the row beneath it - -- Container level, i.e. strictly below the rows (children default to +1), so - -- the plate stays behind every label and accent bar. - navHover:SetFrameLevel(math.max(0, tabContainer:GetFrameLevel() or 1)) - CreateElementBackdrop(navHover, { outline = false, bgColor = { 0, 0, 0, 0 } }) - navHover:Hide() - GUI.navHover = navHover - - local function NavHoverShow(row, alpha) - navHover.owner = row - navHover:ClearAllPoints() - navHover:SetAllPoints(row) - navHover:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, alpha) - navHover:Show() - end - - -- Deferred by one frame ON PURPOSE. The rows tile, so leaving one and - -- entering the next happens in the same frame and WoW does not guarantee - -- which handler runs first. Hiding immediately would put back exactly the - -- dark frame this exists to remove; instead the hide only lands if no row - -- claimed the plate in the meantime. - local function NavHoverHide(row) - if navHover.owner ~= row then return end - C_Timer.After(0, function() - if navHover.owner == row then - navHover:Hide() - navHover.owner = nil - end - end) - end - GUI.HideNavHover = function() - navHover:Hide() - navHover.owner = nil - end - - - -- Content area (right side) - no BackdropTemplate in CreateFrame - -- ★ THE content panel, and therefore the ancestor of every page viewport -- - -- so its edges are the surface every page is clipped against. Snapped for - -- the same structural reason the nav chain is: it is anchored by two corners, - -- Nothing nudges it afterwards, and the offsets going in are the only - -- lever. - -- - -- The BOTTOM one is what mattered. A raw 36 is 50.625 device px at 1.4062 - -- px/unit, i.e. 0.625 above a grid line -- and /df debug pixelcheck reported - -- exactly that on every page it was ever run on: "viewport top+0.00 - -- bot-0.375". The page's own inset was already snapped, but a snapped inset - -- off a fractional PARENT edge is still fractional, so the viewport's bottom - -- clip boundary sat between two device rows. Anything resting against it -- - -- now the See-Also bar, since it was made a footer -- loses part of its - -- border to the cut. - -- - -- Same shape as the original bug at the TOP of the page: the box measures - -- perfect and the surface it is clipped against is what is wrong. Krathe - -- spotted the symmetry before the numbers did. - local content = CreateFrame("Frame", nil, frame) - content:SetPoint("TOPLEFT", tabFrame, "TOPRIGHT", SnapLen(frame, 8), 0) - content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", - SnapLen(frame, -12), SnapLen(frame, 36)) - CreateElementBackdrop(content) - content:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.3) - GUI.contentFrame = content - GUI.tabFrame = tabFrame - - -- ========================================================================= - -- CLICK CASTING PANEL (full width, replaces normal content when active) - -- ========================================================================= - local clickCastPanel = CreateFrame("Frame", nil, frame) - clickCastPanel:SetPoint("TOPLEFT", 12, -64) - clickCastPanel:SetPoint("BOTTOMRIGHT", -12, 36) - CreateElementBackdrop(clickCastPanel) - clickCastPanel:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.3) - clickCastPanel:Hide() - GUI.clickCastPanel = clickCastPanel - - -- ========================================================================= - -- FOOTER BAR (Discord & Donation links + bottom drag handle) - -- ========================================================================= - - -- Bottom drag bar (mirrors titleBar for dragging from the bottom) - local bottomBar = CreateFrame("Frame", nil, frame) - bottomBar:SetHeight(30) - bottomBar:SetPoint("BOTTOMLEFT", 0, 0) - bottomBar:SetPoint("BOTTOMRIGHT", -16, 0) -- Leave space for resize handle - bottomBar:EnableMouse(true) - bottomBar:RegisterForDrag("LeftButton") - bottomBar:SetScript("OnDragStart", function() frame:StartMoving() end) - bottomBar:SetScript("OnDragStop", function() - frame:StopMovingOrSizing() - local point, _, relPoint, x, y = frame:GetPoint() - if DF.db and DF.db.party then - DF.db.party.guiPoint = point - DF.db.party.guiRelPoint = relPoint - DF.db.party.guiX = x - DF.db.party.guiY = y - end - end) - - local footer = CreateFrame("Frame", nil, bottomBar) - footer:SetPoint("BOTTOMLEFT", 12, 8) - footer:SetPoint("BOTTOMRIGHT", -12, 8) - footer:SetHeight(22) - - -- URL copy popup helper - local function ShowURLPopup(url, label) - local popup = GUI.urlPopup - if not popup then - popup = CreateFrame("Frame", "DFURLPopup", UIParent, "BackdropTemplate") - popup:SetSize(380, 80) - popup:SetPoint("CENTER") - GUI:CreatePanelBackdrop(popup, { bgAlpha = 0.98, borderColor = C_ACCENT }) - popup:SetFrameStrata("FULLSCREEN_DIALOG") - popup:SetFrameLevel(250) - popup:EnableMouse(true) - - local popupTitle = popup:CreateFontString(nil, "OVERLAY", "DFFontNormal") - popupTitle:SetPoint("TOP", 0, -10) - popupTitle:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - popup.title = popupTitle - - local editBox = CreateFrame("EditBox", nil, popup, "BackdropTemplate") - editBox:SetPoint("TOPLEFT", 12, -30) - editBox:SetPoint("TOPRIGHT", -12, -30) - editBox:SetHeight(22) - GUI:StyleEditBox(editBox) - editBox:SetAutoFocus(true) - editBox:SetScript("OnEscapePressed", function() popup:Hide() end) - editBox:SetScript("OnEditFocusGained", function(self) self:HighlightText() end) - popup.editBox = editBox - - local hint = popup:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - hint:SetPoint("BOTTOM", 0, 8) - hint:SetText(L["Press Ctrl+C to copy, then Escape to close"]) - hint:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - GUI.urlPopup = popup - end - - popup.title:SetText(label) - popup.editBox:SetText(url) - popup:Show() - popup.editBox:SetFocus() - popup.editBox:HighlightText() - end - GUI.ShowURLPopup = ShowURLPopup - - -- Create a footer link button - local function CreateFooterLink(parent, text, color, url, popupLabel) - local btn = CreateFrame("Button", nil, parent) - btn:SetHeight(22) - - local label = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("CENTER") - label:SetText(text) - label:SetTextColor(color.r, color.g, color.b) - btn:SetWidth(label:GetStringWidth() + 10) - btn.label = label - - btn:SetScript("OnEnter", function() - local h = GUI:LinkHoverColor(color) - label:SetTextColor(h.r, h.g, h.b) - end) - btn:SetScript("OnLeave", function() - label:SetTextColor(color.r, color.g, color.b) - end) - btn:SetScript("OnClick", function() - ShowURLPopup(url, popupLabel) - end) - - return btn - end - - -- Discord link - local discordColor = { r = 0.45, g = 0.53, b = 0.85 } - local discordBtn = CreateFooterLink(footer, L["Need support? Join our Discord"], discordColor, - "https://discord.gg/SDWtduCqnT", L["Join the DandersFrames Discord"]) - discordBtn:SetPoint("LEFT", footer, "LEFT", 2, 0) - - -- Separator - local sep = footer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - sep:SetPoint("LEFT", discordBtn, "RIGHT", 8, 0) - sep:SetText("|") - sep:SetTextColor(C_BORDER.r, C_BORDER.g, C_BORDER.b) - - -- PayPal link - local paypalColor = { r = 0.35, g = 0.65, b = 0.45 } - local donateBtn = CreateFooterLink(footer, L["Support with PayPal"], paypalColor, - "https://paypal.me/dandersframesaddon", L["Support DandersFrames Development"]) - donateBtn:SetPoint("LEFT", sep, "RIGHT", 8, 0) - - -- Separator 2 - local sep2 = footer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - sep2:SetPoint("LEFT", donateBtn, "RIGHT", 8, 0) - sep2:SetText("|") - sep2:SetTextColor(C_BORDER.r, C_BORDER.g, C_BORDER.b) - - -- Patreon link - local patreonColor = { r = 0.90, g = 0.35, b = 0.30 } - local patreonBtn = CreateFooterLink(footer, L["Support with Patreon"], patreonColor, - "https://www.patreon.com/DandersFrames", L["Support DandersFrames on Patreon"]) - patreonBtn:SetPoint("LEFT", sep2, "RIGHT", 8, 0) - - -- Version on the right - local versionText = footer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - versionText:SetPoint("RIGHT", footer, "RIGHT", -2, 0) - versionText:SetText(versionStr .. channelTag) - versionText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.5) - - -- Create the click casting UI content - if DF.ClickCast then - DF.ClickCast:CreateClickCastUI(clickCastPanel) - end - - -- Store min width references for tab switching - local normalMinWidth = minWidth -- 520 - -- Shared minimum width for the "wide" pages (Binds/Click Casting, Aura - -- Designer, Text Designer, Pinned Frames) — their two-panel / tab-strip - -- layouts squash below this. - local wideMinWidth = 850 - - -- Function to show normal Party/Raid content - function GUI:ShowNormalContent() - if clickCastPanel then clickCastPanel:Hide() end - if tabFrame then tabFrame:Show() end - if content then content:Show() end - - -- Restore normal minimum width - frame:SetResizeBounds(normalMinWidth, minHeight, maxWidth, maxHeight) - - -- Update tab availability for current mode (greys out tabs for disabled modes) - GUI:UpdateTabAvailability() - end - - -- Function to show Click Casting content - function GUI:ShowClickCastingContent() - if tabFrame then tabFrame:Hide() end - if content then content:Hide() end - - -- Set larger minimum width for clicks tab - frame:SetResizeBounds(wideMinWidth, minHeight, maxWidth, maxHeight) - - -- If current width is less than clicks min, expand it - local currentWidth = frame:GetWidth() - if currentWidth < wideMinWidth then - frame:SetWidth(wideMinWidth) - end - - if clickCastPanel then - clickCastPanel:Show() - -- Refresh the spell grid - if DF.ClickCast and DF.ClickCast.RefreshSpellGrid then - DF.ClickCast:RefreshSpellGrid() - end - end - end - - -- ========================================================================= - -- SEARCH RESULTS PANEL (inside content area) - -- ========================================================================= - if DF.Search then - DF.Search:CreateResultsPanel(content) - end - - GUI.Tabs = {} - GUI.Pages = {} - - local function SelectTab(name) - -- Hide search results when navigating to a tab - if DF.Search then - DF.Search:HideResults() - end - - -- Clear any "New" section-header badges on the tab we're leaving. - -- The user has had their chance to see the badges; mark them seen - -- persistently so they don't reappear on the next visit. - local leavingTab = GUI.CurrentPageName - if leavingTab and leavingTab ~= name and GUI.pendingSectionBadges[leavingTab] then - for key, badge in pairs(GUI.pendingSectionBadges[leavingTab]) do - if badge and badge.Hide then badge:Hide() end - if DandersFramesDB_v2 then - DandersFramesDB_v2.seenSections = DandersFramesDB_v2.seenSections or {} - DandersFramesDB_v2.seenSections[key] = true - end - end - GUI.pendingSectionBadges[leavingTab] = nil - end - - for k, page in pairs(GUI.Pages) do page:Hide() end - for k, btn in pairs(GUI.Tabs) do - if btn.accent then btn.accent:Hide() end - -- Check if tab is disabled (e.g., during Auto Profile editing) - if btn.disabled then - btn.Text:SetTextColor(0.4, 0.4, 0.4) - btn.Text:SetAlpha(1) - else - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btn.Text:SetAlpha(1) - end - btn.isActive = false - btn:SetBackdropColor(0, 0, 0, 0) -- Reset background when deselected - end - - -- Auto-expand parent category so the selected tab is visible - local tab = GUI.Tabs[name] - if tab and tab.categoryName then - local cat = GUI.Categories[tab.categoryName] - if cat and not cat.expanded then - cat.expanded = true - cat.arrow:SetText("-") - -- Persist state - if DF.db and DF.db.party then - if not DF.db.party.guiExpandedCategories then - DF.db.party.guiExpandedCategories = {} - end - DF.db.party.guiExpandedCategories[cat.name] = true - end - GUI:UpdateTabLayout() - end - end - - -- Wide pages (AD/TD/Pinned) need extra width; set the resize bounds + - -- expand BEFORE building the page so its content lays out at the final - -- width instead of building narrow then staying squashed until a resize. - local WIDE_PAGES = { - auras_auradesigner = true, -- two-panel preview + controls - auras_filterdesigner = true, -- two-column preset list + spell list - text_designer = true, -- two-panel preview + controls - general_pinnedframes = true, -- tab strip + active-set meter - general_nicknames = true, -- wide add-row (Match+Char+Nick+Add) + list columns - } - if WIDE_PAGES[name] then - frame:SetResizeBounds(wideMinWidth, minHeight, maxWidth, maxHeight) - if frame:GetWidth() < wideMinWidth then - frame:SetWidth(wideMinWidth) - end - -- Belt-and-braces: re-assert the width next frame so size-dependent - -- layout settles without a manual resize. A page can build at a - -- pre-layout width (tabs overflow the panel / cards squashed); nudging - -- the width fires the same OnSizeChanged re-flows a resize would. - C_Timer.After(0, function() - if GUI.CurrentPageName ~= name or not frame:IsShown() then return end - local w = frame:GetWidth() - frame:SetWidth(w + 1) - frame:SetWidth(w) - end) - else - frame:SetResizeBounds(normalMinWidth, minHeight, maxWidth, maxHeight) - end - - if GUI.Pages[name] then - -- Set current tab for Search registration - if DF.Search then - local page = GUI.Pages[name] - DF.Search:SetCurrentTab(page.tabName, page.tabLabel) - DF.Search.CurrentSection = nil - end - - GUI.Pages[name]:Show() - -- Tab switching uses the cache-aware path so revisiting a tab is cheap. - GUI.Pages[name]:RefreshCached() - if GUI.Pages[name].RefreshStates then GUI.Pages[name]:RefreshStates() end - end - local nc = GetThemeColor() - if GUI.Tabs[name] then - if GUI.Tabs[name].accent then - GUI.Tabs[name].accent:Show() - GUI.Tabs[name].accent:SetColorTexture(nc.r, nc.g, nc.b, 1) - end - GUI.Tabs[name].Text:SetTextColor(nc.r, nc.g, nc.b) - GUI.Tabs[name].isActive = true - -- Mark "New" badge as seen - if GUI.Tabs[name].newBadge and GUI.Tabs[name].newBadge:IsShown() then - GUI.Tabs[name].newBadge:Hide() - if DandersFramesDB_v2 then - DandersFramesDB_v2.seenTabs = DandersFramesDB_v2.seenTabs or {} - DandersFramesDB_v2.seenTabs[name] = true - end - -- Hide parent category badge if no remaining children have new badges - local catName = GUI.Tabs[name].categoryName - local cat = catName and GUI.Categories[catName] - if cat and cat.newBadge and cat.newBadge:IsShown() then - local anyNew = false - for _, child in ipairs(cat.children) do - if child.newBadge and child.newBadge:IsShown() then - anyNew = true - break - end - end - if not anyNew then cat.newBadge:Hide() end - end - end - end - GUI.CurrentPageName = name - UpdateThemeColors() - end - GUI.SelectTab = SelectTab - - GUI.RefreshCurrentPage = function() - -- Don't refresh regular pages when in clicks mode (they use DF.db which doesn't have "clicks") - if GUI.SelectedMode == "clicks" then - return - end - if GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] then - GUI.Pages[GUI.CurrentPageName]:Refresh() - if GUI.Pages[GUI.CurrentPageName].RefreshStates then - GUI.Pages[GUI.CurrentPageName]:RefreshStates() - end - UpdateThemeColors() - end - -- Refresh override indicators - RefreshAllOverrideIndicators() - -- A designer preset bar can need re-reading even when its page skipped - -- the rebuild (the sharing glyph tracks the OTHER mode's preset). - if GUI.RefreshDesignerPresetBars then GUI:RefreshDesignerPresetBars() end - end - - -- Invalidate EVERY page's build cache so the next time each tab is shown it - -- rebuilds from scratch (via RefreshCached -> DoBuild). The page cache is - -- keyed on mode (party/raid) only, NOT on the active auto-layout/profile, so - -- switching between raid auto-layouts leaves cacheValid=true and tabs re-show - -- stale geometry — most visibly the Aura Designer / Text Designer frame - -- previews, which size their mock frame to the layout's frameWidth/Height at - -- build time and so stay stuck at the first-edited layout's size. Call this - -- whenever the active layout changes (enter/exit auto-profile editing). - GUI.InvalidateAllPages = function() - if not GUI.Pages then return end - for _, page in pairs(GUI.Pages) do - if page.Invalidate then page:Invalidate() end - end - end - - -- Category system - GUI.Categories = {} - local categoryY = -8 - - local function CreateCategory(name, label) - local cat = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") - cat:SetPoint("TOPLEFT", 4, categoryY) - cat:SetPoint("TOPRIGHT", -4, categoryY) - -- Placeholder only: UpdateTabLayout owns the real height and sets it to - -- the row STRIDE so the rows tile with no dead band between them (see the - -- comment there -- that band was the hover flash). Matched here so the - -- row is never briefly the wrong size before the first layout pass. - cat:SetHeight(SnapLen(cat, 30) or 30) - -- Hover plate only: transparent at rest, tinted by OnEnter/OnLeave. - CreateElementBackdrop(cat, { outline = false, bgColor = { 0, 0, 0, 0 } }) - cat.name = name - cat.children = {} - - -- Restore saved state (default collapsed) - local savedStates = DF.db and DF.db.party and DF.db.party.guiExpandedCategories - cat.expanded = savedStates and savedStates[name] or false - - -- Expand/collapse indicator (simple minus/plus) - cat.arrow = cat:CreateFontString(nil, "OVERLAY", "DFFontNormal") - cat.arrow:SetPoint("LEFT", 6, 0) - cat.arrow:SetText(cat.expanded and "-" or "+") - cat.arrow:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - cat.Text = cat:CreateFontString(nil, "OVERLAY", "DFFontNormal") - cat.Text:SetPoint("LEFT", 20, 0) - cat.Text:SetText(label) - cat.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- "New" badge for categories — shown when any child tab has a new badge - local catNewBadge = cat:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - catNewBadge:SetPoint("RIGHT", cat, "RIGHT", -8, 0) - catNewBadge:SetText(L["New"]) - catNewBadge:SetTextColor(1, 0.82, 0) - catNewBadge:Hide() - cat.newBadge = catNewBadge - - -- Hover is the SHARED moving plate, not this row's own backdrop -- see - -- navHover. The row's backdrop stays permanently transparent (SelectTab - -- still resets it, harmlessly). - cat:SetScript("OnEnter", function(self) NavHoverShow(self, 0.3) end) - cat:SetScript("OnLeave", function(self) NavHoverHide(self) end) - cat:SetScript("OnClick", function(self) - self.expanded = not self.expanded - self.arrow:SetText(self.expanded and "-" or "+") - -- Persist state - if DF.db and DF.db.party then - if not DF.db.party.guiExpandedCategories then - DF.db.party.guiExpandedCategories = {} - end - DF.db.party.guiExpandedCategories[self.name] = self.expanded or nil - end - GUI:UpdateTabLayout() - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - - GUI.Categories[name] = cat - -- Only add to CategoryOrder if not already in the explicit list (Options.lua sets it) - local found = false - for _, v in ipairs(GUI.CategoryOrder) do - if v == name then found = true break end - end - if not found then - tinsert(GUI.CategoryOrder, name) - end - categoryY = categoryY - 30 - return cat - end - - -- `hidden`: build and register the page exactly as normal, but keep its row - -- OUT of the sidebar. For a page that is DEPRECATED but not yet deleted -- - -- the code stays whole and greppable, GUI.Pages/GUI.Tabs still resolve so - -- nothing that walks them has to special-case it, and deleting one word at - -- the call site puts the page back. See DEPRECATED-TARGETED-SPELLS. - local function CreateSubTab(categoryName, name, label, hidden) - local cat = GUI.Categories[categoryName] - if not cat then return end - - local btn = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") - btn:SetHeight(SnapLen(btn, 28) or 28) -- placeholder; see CreateCategory - -- Hover/selected plate only: the accent bar carries the selected state. - CreateElementBackdrop(btn, { outline = false, bgColor = { 0, 0, 0, 0 } }) - btn.isTab = true - btn.tabName = name - btn.categoryName = categoryName - - -- Left accent bar - btn.accent = btn:CreateTexture(nil, "OVERLAY") - btn.accent:SetPoint("TOPLEFT", 0, 0) - btn.accent:SetPoint("BOTTOMLEFT", 0, 0) - btn.accent:SetWidth(3) - btn.accent:Hide() - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 24, 0) - btn.Text:SetText(label) - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- "New" badge — shown for tabs in GUI.NewTabs until the user opens them - local newBadge = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - newBadge:SetPoint("RIGHT", btn, "RIGHT", -8, 0) - newBadge:SetText(L["New"]) - newBadge:SetTextColor(1, 0.82, 0) - newBadge:Hide() - btn.newBadge = newBadge - if GUI.NewTabs[name] - and not (DandersFramesDB_v2 and DandersFramesDB_v2.seenTabs - and DandersFramesDB_v2.seenTabs[name]) then - newBadge:Show() - -- Also show "New" on the parent category - if cat and cat.newBadge then - cat.newBadge:Show() - end - end - - -- Shared moving plate (see navHover). The active tab still shows no hover - -- tint -- its accent bar and label colour are its state -- so crossing it - -- parks the plate rather than moving it. - btn:SetScript("OnEnter", function(self) - if not self.isActive then NavHoverShow(self, 0.5) end - end) - btn:SetScript("OnLeave", function(self) NavHoverHide(self) end) - btn:SetScript("OnClick", function(self) - if self.disabled then return end - SelectTab(name) - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - - -- Create the page - local page = CreateFrame("ScrollFrame", nil, content, "ScrollFrameTemplate") - -- ★ The insets are SNAPPED, and this is the frame the whole pixel grid - -- was hanging off. A ScrollFrame clips to its own rect, so its top edge is - -- the boundary every box near the top of a page is cut against -- and a - -- raw 8-unit inset is 11.25 DEVICE PIXELS at 0.75 scale, i.e. a quarter - -- pixel off the grid. /df debug pixelcheck measured exactly that: viewport - -- top-0.25, scroll child top-0.25 (it inherits the phase), while every - -- box inside reported a flawless top+0.00. The boxes were never the - -- problem; the surface they are clipped against was. - -- - -- Nothing corrects it afterwards -- this frame is anchored by TWO - -- corners, so even the geometry correction that used to exist skipped it - -- (nudging a two-corner frame resizes it rather than moving it). The one - -- surface every page is measured against was the one it could not touch. - -- Snapping the OFFSETS is the fix that works for a two-corner frame: - -- correct the numbers going in, since the box itself can't be nudged. - local inset = SnapLen(content, 8) or 8 - page:SetPoint("TOPLEFT", inset, -inset) - page:SetPoint("BOTTOMRIGHT", -inset, inset) - - StyleScrollBar(page) - - local child = CreateFrame("Frame", nil, page) - -- Snapped for the same reason: content is positioned against this child, - -- so a fractional width put every right edge inside it off-grid too - -- (pixelcheck reported w-0.16). - child:SetSize(SnapLen(page, (content:GetWidth() or 0) - 30) or 1, 1) - page:SetScrollChild(child) - page.child = child - page.tabName = name - page.tabLabel = label - page:Hide() - page.Refresh = function() end - - GUI.Tabs[name] = btn - GUI.Pages[name] = page - if hidden then - -- Staying out of cat.children is what actually hides it: UpdateTabLayout - -- only ever anchors and sizes that list, so this row is never given a - -- position. Hidden explicitly too, so nothing rests on that detail. - btn:Hide() - else - table.insert(cat.children, btn) - end - - return page - end - - -- Update tab positions based on expanded/collapsed state - function GUI:UpdateTabLayout() - -- Every number here is snapped, and the running `y` is built ONLY out of - -- snapped steps, so each row lands on the pixel grid rather than each one - -- picking up a different sub-pixel phase down the list. The nav rows are - -- two-corner anchored, so nothing downstream could correct - -- them (and would not anyway -- they are not structural boxes): the - -- offsets going in are the only lever, same as the page viewport. - -- - -- The rows TILE: each one's height IS the stride to the next, so there is - -- no strip between them where the cursor is over nothing. - -- - -- They used to be 28 tall on a 30 stride (and 26 on 28), leaving a 2-unit - -- dead band -- about 3 device pixels -- between every pair. /df debug navprobe - -- caught what that costs: crossing the band puts mouse focus on the plain - -- container Frame behind the list for a single frame, with NO row lit, so - -- the hover plate blinks off and back on mid-sweep. That one dark frame is - -- the "ghost" -- and because whether you land in the band depends on how - -- fast you are moving, the same crossing sometimes flashed and sometimes - -- did not, which is why it looked like a render hitch rather than layout. - -- - -- Tiling costs nothing visually: only one row is ever lit, so the plates - -- have no neighbour to sit flush against. The height is taken FROM the - -- stride rather than snapped separately, so they cannot disagree by a - -- pixel and reopen a hairline gap. - -- Park the shared hover plate: expanding or collapsing can hide the row it - -- is anchored to, and a plate anchored to a hidden frame is a stray. - if GUI.HideNavHover then GUI.HideNavHover() end - - local container = GUI.tabContainer - local y = SnapLen(container, -8) or -8 - local catStride = SnapLen(container, 30) or 30 - local tabStride = SnapLen(container, 28) or 28 - - for _, catName in ipairs(GUI.CategoryOrder) do - local cat = GUI.Categories[catName] - if cat then - cat:ClearAllPoints() - cat:SetPoint("TOPLEFT", 0, y) - cat:SetPoint("TOPRIGHT", 0, y) - cat:SetHeight(catStride) - y = y - catStride - - if cat.expanded then - for _, btn in ipairs(cat.children) do - -- Party-only tabs are hidden entirely in raid mode. - if btn.partyOnly and GUI.SelectedMode == "raid" then - btn:Hide() - else - btn:Show() - btn:ClearAllPoints() - btn:SetPoint("TOPLEFT", 0, y) - btn:SetPoint("TOPRIGHT", 0, y) - btn:SetHeight(tabStride) - y = y - tabStride - end - end - else - for _, btn in ipairs(cat.children) do - btn:Hide() - end - end - end - end - - -- Update scroll child height - local totalHeight = math.abs(y) + 20 - GUI.tabContainer:SetHeight(totalHeight) - end - - -- Re-sync each category's expanded state from the current profile's saved - -- state and relayout the sidebar. Categories read their state once at - -- creation, so a profile switch needs this to reflect the new profile's - -- expanded/collapsed tabs without a /reload. - function GUI:RefreshCategoryStates() - local saved = DF.db and DF.db.party and DF.db.party.guiExpandedCategories - for name, cat in pairs(self.Categories) do - cat.expanded = (saved and saved[name]) or false - if cat.arrow then cat.arrow:SetText(cat.expanded and "-" or "+") end - end - self:UpdateTabLayout() - end - - -- Store category order - GUI.CategoryOrder = {} - - -- (Removed) CreateTab — "legacy single tab support", a thin wrapper over - -- CreateSubTab("tools", ...). Every page registers through CreateSubTab directly - -- now, so it had no callers. - - local function BuildPage(page, builderFunc) - -- Internal: construct all widget frames for the current mode. - -- Called on first visit and whenever the cache is invalidated. - -- Always finishes by calling RefreshStates() so callers don't need to. - local function DoBuild(self) - local db = DF.db[GUI.SelectedMode] - if not db then return end - - -- Retire old children: hide, detach anchors, and reparent to the - -- trash frame so they leave the GUI frame hierarchy entirely. - -- WoW cannot GC frames, but a detached subtree is not traversed - -- during drag layout recalculation. - if self.children then - local trash = GUI._trashFrame - for _, child in ipairs(self.children) do - child:Hide() - child:ClearAllPoints() - if trash then child:SetParent(trash) end - end - end - self.children = {} - self.child.ThemeListeners = {} - -- Propagate RefreshStates to child so widgets can call it - self.child.RefreshStates = function() self:RefreshStates() end - local parent = self.child - - -- col = 1, 2, or "both". THE PAGE LAYOUT STANDARD lives here, because - -- this is the call that decides it and the only thing every page has - -- in common: - -- - -- column 1 -- structure and geometry, in this order: - -- Settings -> [Layout] -> [Size] -> Position - -- column 2 -- styling, in this order: - -- Appearance -> Border -> element extras (text, bars) - -- "both" -- ONLY on a page that genuinely needs the width - -- (Pinned Frames, Nicknames). It is also a sync point: - -- it takes the lower of the two columns and drops both - -- to it, whether or not you wanted that. - -- - -- Columns rather than a flat order, because a page is read as two - -- columns and not as a sequence -- putting Appearance third and - -- Border fifth still separates them on screen if they land on - -- opposite sides. Grouping by column keeps the two natural pairs - -- (Layout+Position, Appearance+Border) together where the eye is. - -- - -- ⚠ The split only MEANS anything where both categories are present. - -- A surface holding nothing but geometry (an aura page's Layout - -- section) or nothing but styling (its Appearance section) has no - -- left/right distinction to preserve, so its boxes fill both columns - -- for balance and reading order instead. Forcing the rule there - -- empties one column, which is worse than the inconsistency it was - -- meant to fix. - -- - -- Sections (GUI:CreateCollapsibleSection) hold a page's boxes when - -- the page needs a second level -- either PARALLEL SUB-FEATURES - -- (Icons, Highlights, Health Bar) or broad CATEGORIES whose box - -- names only make sense inside them (the aura pages, where Layout - -- and Duration Bar each contain their own "Settings" box). The rule - -- above then applies within each section rather than across the - -- page. A page that needs neither stays as plain boxes. - local function Add(widget, height, col) - table.insert(self.children, widget) - widget:SetParent(parent) - widget.layoutHeight = ResolveRowHeight(widget, height) - widget.layoutCol = col or 1 - return widget - end - - -- Disabled-mode handling: when the current mode is off in General - -- settings, replace the page content with a single banner instead - -- of rendering any controls. Whitelisted pages (General, Profiles, - -- Debug, Targeted List, Personal Targeted) always render normally. - if GUI:IsTabDisabledForCurrentMode(self.tabName) then - local banner = GUI:CreateInfoBanner(parent, { tone = "info" }) - banner:SetText(GUI.SelectedMode == "raid" - and (L["Raid frames are currently disabled. Changes here will apply after re-enabling Raid in the General tab and reloading."]) - or (L["Party frames are currently disabled. Changes here will apply after re-enabling Party in the General tab and reloading."])) - table.insert(self.children, banner) - banner.layoutCol = "both" - self.builtForMode = GUI.SelectedMode - self.builtForDisabled = true - self.cacheValid = true - self:RefreshStates() - return - end - - local function AddSpace(h, col) - local spacer = CreateFrame("Frame", nil, parent) - spacer:SetSize(1, h) - spacer.layoutHeight = h - spacer.layoutCol = col or "both" - table.insert(self.children, spacer) - return spacer - end - - -- Sync point: forces both columns to align to the same Y position - local function AddSyncPoint() - local sync = CreateFrame("Frame", nil, parent) - sync:SetSize(1, 1) - sync.isSyncPoint = true - sync.layoutHeight = 0 - sync.layoutCol = "both" - table.insert(self.children, sync) - end - - builderFunc(self, db, Add, AddSpace, AddSyncPoint) - self.builtForMode = GUI.SelectedMode - self.builtForDisabled = false - self.cacheValid = true - self:RefreshStates() - end - - -- Invalidate this page's cache so the next RefreshCached() rebuilds. - page.Invalidate = function(self) - self.cacheValid = false - self.builtForMode = nil - end - - -- Cache-aware refresh — used ONLY by tab switching. If the cached build - -- is still valid for the current mode and enabled/disabled state, run the - -- cheap visibility/layout pass; otherwise rebuild. This is the perf path - -- that makes revisiting a tab cheap. - page.RefreshCached = function(self) - local db = DF.db[GUI.SelectedMode] - -- Guard against nil db (e.g., when "clicks" mode is selected) - if not db then return end - - local isDisabled = GUI:IsTabDisabledForCurrentMode(self.tabName) - if self.cacheValid - and self.builtForMode == GUI.SelectedMode - and self.builtForDisabled == isDisabled then - self:RefreshStates() - return - end - - -- Cache miss: build fresh for this mode. - -- DoBuild sets cacheValid and calls RefreshStates() before returning. - DoBuild(self) - end - - -- Refresh() ALWAYS rebuilds. This is its historical contract: callers - -- invoke it after mutating data (adding/removing list items, reset/copy/ - -- sync, profile changes, etc.) and rely on the page being reconstructed. - -- Only tab switching uses the cache, via RefreshCached(). - page.Refresh = function(self) - local db = DF.db[GUI.SelectedMode] - -- Guard against nil db (e.g., when "clicks" mode is selected) - if not db then return end - DoBuild(self) - end - - page.RefreshStates = function(self) - if not self.children then return end - local db = DF.db[GUI.SelectedMode] - if not db then return end - - -- First pass: handle SettingsGroups - layout their children and calculate heights - for _, widget in ipairs(self.children) do - if widget.isSettingsGroup then - -- Layout children within the group (handles hideOn internally) - widget:LayoutChildren() - -- Process disableOn for group children - widget:RefreshChildStates() - end - end - - -- Second pass: handle regular widgets and group visibility - for _, widget in ipairs(self.children) do - -- Keep any blocked-overlay (top-level widget or group) in sync. - -- Skip SettingsGroup children - they're handled by their parent group - if widget.settingsGroup then - -- Already handled by group's LayoutChildren - elseif widget.isSettingsGroup then - -- For groups, check collapsible section state AND group-level hideOn - local shouldHide = false - - -- Check if parent collapsible section is collapsed - if widget.collapsibleSection and not widget.collapsibleSection.expanded then - shouldHide = true - end - - -- Check group's own hideOn - if not shouldHide and widget.hideOn then - shouldHide = widget.hideOn(db) - end - - if shouldHide then - widget:Hide() - else - widget:Show() - end - else - -- Regular widget processing - if widget.disableOn then - local shouldDisable = widget.disableOn(db) - if widget.SetEnabled then - widget:SetEnabled(not shouldDisable) - end - end - - -- Check if widget should be hidden - local shouldHide = false - - -- First check if parent collapsible section is collapsed - if widget.collapsibleSection and not widget.collapsibleSection.expanded then - shouldHide = true - end - - -- Then check widget's own hideOn - if not shouldHide and widget.hideOn then - shouldHide = widget.hideOn(db) - end - - if shouldHide then - widget:Hide() - else - widget:Show() - -- Call refreshContent hook for dynamic content updates - if widget.refreshContent then - widget:refreshContent(db) - end - end - end - end - - -- Determine column layout based on content area width. - -- Two-column settings groups are 280px wide and placed at x=5 (right - -- edge 285), while column 2 starts at contentWidth/2 — so they begin to - -- overlap once contentWidth drops below ~570. minColumnWidth must exceed - -- the 280 group width (was a stale 270) so the layout collapses to one - -- column BEFORE the columns touch, leaving a ~10px gutter at the cutover - -- instead of overlapping for the last ~10px. - local contentWidth = GUI.contentFrame and GUI.contentFrame:GetWidth() or 540 - local minColumnWidth = 285 -- ≥ the 280 group width + a small gutter - local usesTwoColumns = contentWidth >= (minColumnWidth * 2 + 20) - - -- Account for scrollbar and padding when calculating usable width - local usableWidth = contentWidth - 40 -- Extra padding for scrollbar - - -- Check if editing banner is active (adds 50px at top) - local bannerOffset = 0 - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - bannerOffset = 50 - end - - -- Layout - adjust column positions based on available width - local x1, maxY = 5, 0 - local col2X = usesTwoColumns and math.floor(contentWidth / 2) or x1 - local y1, y2 = -5 - bannerOffset, -5 - bannerOffset - - -- First, position any right-aligned elements (like Copy buttons) at absolute top-right - for _, widget in ipairs(self.children) do - if widget.rightAlign and widget:IsShown() then - widget:ClearAllPoints() - -- Both offsets snapped: this widget is the HEAD of the - -- Reset/Sync/Copy chain, so a fraction here is inherited by - -- every button to its left, and the buttons are no longer - -- nudged back onto the grid individually. - widget:SetPoint("TOPRIGHT", self.child, "TOPRIGHT", - SnapLen(self.child, -10), SnapLen(self.child, -5 - bannerOffset)) - end - end - - -- Reserve space below the right-aligned row (Reset Page / Sync / Copy). - -- - -- This used to subtract a flat 40 with the comment "button height ~26 + - -- 14 padding" -- a GUESS, not a measurement. Any page whose row is not - -- exactly 26 tall got a different gap under it, so the first box sat at - -- a different height on every page and the row appeared to shift. - -- Measure the row instead and add a fixed pad, so the gap below the - -- buttons is identical everywhere by construction. - -- - -- The scan is ALSO gated on IsShown() now, matching the positioning loop - -- above. It was not, so a page carrying a HIDDEN right-aligned widget - -- still reserved the 40 and opened with an empty band above its first - -- box -- the pages Krathe saw "starting further down" with nothing there. - local RIGHT_ROW_PAD = 14 - local rightRowH = 0 - for _, widget in ipairs(self.children) do - if widget.rightAlign and widget:IsShown() then - rightRowH = math.max(rightRowH, widget:GetHeight() or 0) - end - end - if rightRowH > 0 then - -- Snapped for the same reason every other layout number is: an - -- unsnapped offset puts everything below it off the pixel grid. - local reserve = SnapLen(self.child, rightRowH + RIGHT_ROW_PAD) - y1 = y1 - reserve - y2 = y2 - reserve - end - - for _, widget in ipairs(self.children) do - -- Skip widgets that belong to a SettingsGroup (they're positioned by the group) - if widget.settingsGroup then - -- Do nothing - parent group handles positioning - elseif widget.rightAlign then - -- Already positioned above, skip - elseif widget.isSyncPoint then - -- Sync point: align both columns to the lowest Y position - local syncY = math.min(y1, y2) - y1 = syncY - y2 = syncY - elseif widget:IsShown() then - -- For SettingsGroups, use calculated height - local h = widget.layoutHeight or 0 - if widget.isSettingsGroup and widget.calculatedHeight then - h = widget.calculatedHeight - end - - widget:ClearAllPoints() - - -- Set height for frame-based widgets (like header containers) - if widget.text and widget.SetHeight and h > 0 then - widget:SetHeight(h) - end - - -- Apply indent offset if specified (for child/sub-options) - -- Supports: true (20px), or a number for multiple levels (e.g. 2 = 40px) - local indentOffset = 0 - if widget.indent then - if type(widget.indent) == "number" then - indentOffset = widget.indent * 20 - else - indentOffset = 20 - end - end - - -- Snap the offsets and widths this loop hands out, for the same - -- reason CreateSettingsGroup:LayoutChildren does: a widget placed - -- straight onto the page (a banner, a full-width note, a - -- collapsible section) never passes through a group, so this is - -- the only place its geometry can be put on the grid. See SnapLen. - local snapX = SnapLen(widget, x1 + indentOffset) - - if widget.layoutCol == "both" then - local startY = math.min(y1, y2) - widget:SetPoint("TOPLEFT", snapX, SnapLen(widget, startY)) - -- Set width to span both columns (with scrollbar padding) - widget:SetWidth(SnapLen(widget, usableWidth - indentOffset)) - y1 = startY - h - y2 = startY - h - elseif widget.layoutCol == 2 and usesTwoColumns then - widget:SetPoint("TOPLEFT", SnapLen(widget, col2X + indentOffset), - SnapLen(widget, y2)) - -- Reduce width for indented widgets to maintain alignment - if indentOffset > 0 and widget.SetWidth then - local defaultColWidth = math.floor((usableWidth - 20) / 2) - widget:SetWidth(SnapLen(widget, defaultColWidth - indentOffset)) - end - y2 = y2 - h - else - -- Column 1, or column 2 when in single-column mode - widget:SetPoint("TOPLEFT", snapX, SnapLen(widget, y1)) - -- Reduce width for indented widgets to maintain alignment - if indentOffset > 0 and widget.SetWidth then - local defaultColWidth = math.floor((usableWidth - 20) / 2) - widget:SetWidth(SnapLen(widget, defaultColWidth - indentOffset)) - end - y1 = y1 - h - end - - local currentBottom = math.min(y1, y2) - if math.abs(currentBottom) > maxY then maxY = math.abs(currentBottom) end - end - end - local contentH = maxY + 40 + bannerOffset - - -- FOOTER: the See-Also bar is designed as one, so on a page whose - -- content does not fill the viewport it should sit at the BOTTOM - -- rather than floating halfway down with dead space under it. On a - -- page that does fill (or overflow) the viewport it already lands at - -- the end of the flow, which reads correctly -- so this only moves it - -- when there is spare room, and the long-page case is untouched. - -- - -- Done by stretching the scroll child to the viewport height and - -- anchoring the footer to the child's BOTTOM: the child is what the - -- flow is measured against, so this keeps the footer inside the - -- scrolled content (it still scrolls with a long page) instead of - -- floating over it. - local footer - for _, w in ipairs(self.children) do - if w.isPageFooter and w:IsShown() then footer = w break end - end - if footer then - local viewH = self:GetHeight() or 0 - if viewH > contentH then - contentH = viewH - footer:ClearAllPoints() - footer:SetPoint("BOTTOMLEFT", self.child, "BOTTOMLEFT", - SnapLen(self.child, x1), SnapLen(self.child, GUI.Space.footer)) - footer:SetWidth(SnapLen(footer, usableWidth)) - end - end - - self.child:SetHeight(contentH) - - -- Update scroll child width to match content area - if self.child and GUI.contentFrame then - self.child:SetWidth(GUI.contentFrame:GetWidth() - 30) - end - end - end - - -- Trash frame: detached from the GUI hierarchy. Old page children are - -- reparented here on rebuild so they don't contribute to the frame - -- traversal cost during window drag layout recalculation. - GUI._trashFrame = CreateFrame("Frame") - GUI._trashFrame:Hide() - - -- Invalidate all page caches (call before profile/mode switches so each - -- page rebuilds with a fresh db reference on its next visit). - function GUI:InvalidateAllPages() - for _, page in pairs(self.Pages) do - if page.Invalidate then page:Invalidate() end - end - end - - -- Invalidate a single page by name. - function GUI:InvalidatePage(name) - local page = self.Pages[name] - if page and page.Invalidate then page:Invalidate() end - end - - -- Load pages from Options file - if DF.SetupGUIPages then - DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) - end - - -- Setup Auto Profiles editing banner - if DF.AutoProfilesUI and DF.AutoProfilesUI.SetupEditingBanner then - DF.AutoProfilesUI:SetupEditingBanner() - end - - -- Update tab layout after all tabs created - GUI:UpdateTabLayout() - - UpdateThemeColors() - - -- Apply tab availability for current mode (greys out disabled-mode tabs) - GUI:UpdateTabAvailability() - - -- Select first subtab - if GUI.CategoryOrder[1] then - local firstCat = GUI.Categories[GUI.CategoryOrder[1]] - if firstCat and firstCat.children[1] then - SelectTab(firstCat.children[1].tabName) - end - end -end +local addonName, DF = ... +local GUI = {} +DF.GUI = GUI +local L = DF.L + +-- Mutable module state. +-- These four were file-scope locals, and two of them are read and written more +-- than 6,000 lines apart. That is harmless in one file and silently broken the +-- moment the file is split: a `local` re-declared in the second half becomes an +-- independent variable, so the two halves stop sharing state with no error. +-- Holding them on one table keeps a future split honest -- each part reads the +-- same table via `local S = GUI._state`. Read-only aliases (the C_* colours, L, +-- GUI itself) do not need this: re-declaring those yields the same value. +-- Private by convention -- nothing outside this file should touch GUI._state. +local S = {} +GUI._state = S + +-- Shared private helpers. +-- A `local function` is invisible to any other file, so a helper used thousands +-- of lines from its declaration pins this file together: it cannot be split +-- while a later section still calls it. Publishing the wide-reaching ones here +-- lets a sibling file re-declare `local X = GUI._priv.X` -- the same function +-- object, so no call site changes and behaviour is identical. +-- Only helpers spanning >1000 lines are listed; narrow ones stay private. +-- Private by convention -- nothing outside GUI/ should touch GUI._priv. +local P = {} +GUI._priv = P + +-- ========================================================================= +-- MODERN UI CONSTANTS & STYLING (Matching Original v2.3.8) +-- ========================================================================= + +local C_BACKGROUND = {r = 0.08, g = 0.08, b = 0.08, a = 0.95} -- Dark charcoal +local C_PANEL = {r = 0.12, g = 0.12, b = 0.12, a = 1} -- Slightly lighter +local C_ELEMENT = {r = 0.18, g = 0.18, b = 0.18, a = 1} -- Element backgrounds +local C_BORDER = {r = 0.25, g = 0.25, b = 0.25, a = 1} -- Subtle borders +local C_ACCENT = {r = 0.45, g = 0.45, b = 0.95, a = 1} -- Party Purple-Blue +local C_RAID = {r = 1.0, g = 0.5, b = 0.2, a = 1} -- Raid Orange +local C_HOVER = {r = 0.22, g = 0.22, b = 0.22, a = 1} +local C_TEXT = {r = 0.9, g = 0.9, b = 0.9, a = 1} +local C_TEXT_DIM = {r = 0.6, g = 0.6, b = 0.6, a = 1} +local C_WARNING = {r = 0.95, g = 0.35, b = 0.35, a = 1} -- Soft red: behaviour-change / caution notes + +-- Exported palette: other files should theme against these shared tables instead +-- of re-declaring private copies or hardcoding the raw numbers. These are the +-- SAME table references as the locals above. For the mode-aware accent, use +-- GUI.GetThemeColor() (returns party purple or raid orange). +GUI.Colors = { + background = C_BACKGROUND, + panel = C_PANEL, + element = C_ELEMENT, + border = C_BORDER, + accent = C_ACCENT, -- party purple + raid = C_RAID, -- raid orange + hover = C_HOVER, + text = C_TEXT, + textDim = C_TEXT_DIM, + warning = C_WARNING, -- soft red for behaviour-change / caution notes +} + +-- Dialog chrome. Popup.lua is a standalone dialog rather than a settings page and +-- wanted the same handful of extras on top of the shared neutrals — so it had +-- grown a private copy of the WHOLE palette (11 hardcoded colours), matching +-- today only by luck. (WizardBuilder.lua was the other such dialog, since +-- deleted as dead code — hence "three separate copies" in the note below.) +-- One owner: the neutrals below are the SAME tables as GUI.Colors, so they +-- theme-track in lockstep, and only what genuinely differs is declared here. +-- Read-only by convention — these tables are shared, so nothing may mutate them. +GUI.DialogColors = { + -- Dialogs use the SAME ground as the pages. This was a bespoke 0.97 in three + -- separate copies, a shade denser than the pages' 0.95 for no reason anyone + -- could point at; consolidating the copies made the difference visible and + -- it went. Chrome now reads identically whether it's a page or a dialog. + background = C_BACKGROUND, + panel = C_PANEL, + element = C_ELEMENT, + border = C_BORDER, + accent = C_ACCENT, -- fallback only; live dialogs read GetThemeColor() + hover = C_HOVER, + text = C_TEXT, + textDim = C_TEXT_DIM, + selected = {r = 0.28, g = 0.28, b = 0.45, a = 1}, + -- Status pair for dialog content (valid/invalid rows, ok/error dots). + green = {r = 0.2, g = 0.9, b = 0.2}, + red = {r = 0.9, g = 0.25, b = 0.25}, + orange = {r = 0.85, g = 0.55, b = 0.1}, +} + +-- Canonical row heights (the "airier" scale). A fixed-height widget factory stamps its own slot +-- height onto the widget (widget.preferredHeight + widget.fixedRowHeight), so the layout uses THAT +-- and a call-site number can't make the same widget type render at a different height on a different +-- page. New callers can omit the height entirely; legacy call-site numbers on fixed widgets are +-- ignored (harmless, strippable later). Variable widgets (labels, headers, spacers) are NOT stamped +-- and keep whatever height they are given. One place to retune the whole GUI's vertical rhythm. +-- THE vertical rhythm of the whole GUI. +-- +-- These are SLOT heights, not gaps -- LayoutChildren stacks rows flush +-- (y = y - height), so the gap the eye sees between two rows is: +-- +-- (slot - content) of the row above + (content's top inset) of the row below +-- +-- which means a row whose content is short inside a tall slot silently gets a big +-- gap. That is how the GUI ended up with a 4x spread. /df debug gapcheck measured it +-- across four pages (267 rows), and the content heights came back IDENTICAL on +-- every page, so the slots can be derived rather than guessed: +-- +-- kind content old slot old gap new slot new gap +-- slider 32.0 55 23.0 46 14 +-- dropdown 39.8 55 15.2 54 14 +-- editbox 39.0 55 16.0 53 14 +-- colorpicker 23.9 30 6.1 38 14 +-- checkbox 18.2 30 11.8* 35 14* +-- header 11.9 34 / 40 11.0 / 17.0 37 14 +-- +-- So: ONE gap, and every slot is content + RowGap. 14 is not arbitrary -- it is +-- what the dropdown rows already had (15.2), the one spacing Krathe confirmed +-- reads correctly. Sliders lose 9px of slack, colour pickers gain 8. +-- +-- * the checkbox's content sits 2.9px below its slot top, so a row landing ON a +-- checkbox reads 14 + 2.9. Zeroing that would mean re-anchoring the checkbox +-- art itself, which moves the tick 3px for 3px -- not worth it. +-- +-- A header keeps its 11.1px top inset, so the gap ABOVE a header is 14 + 11.1. +-- That is deliberate: a section title wants air above it and to sit close to +-- what it labels. +GUI.RowGap = 14 + +-- ...with ONE exception: a RUN of the same COMPACT row type closes up. +-- +-- A uniform gap everywhere is right between DIFFERENT kinds -- that is the +-- boundary the eye uses to tell one control apart from the next. But eight +-- checkboxes in a column are one list, not eight things, and 14 between each of +-- them reads as a stack of unrelated rows. So consecutive rows of the same +-- compact kind get RowGapTight, and the first row of a different kind after them +-- gets the full RowGap back. Same spacing between TYPES, tighter within a type. +-- +-- Compact means the label sits INLINE with the control (a checkbox's text is +-- beside its tick). Slider, dropdown and edit box are deliberately NOT compact: +-- their label sits ABOVE the control, so tightening the gap there would push the +-- next row's label into the control above it -- the same reasoning already +-- recorded on labelPad, and the reason a stack of sliders needs real air even +-- though a stack of checkboxes does not. +GUI.RowGapTight = 8 +GUI.RowCompact = { + checkbox = true, + toggle = true, + colorpicker = true, +} + +-- PAGE-level spacing, a different axis from the row rhythm above: AddSpace +-- inserts a spacer into the page's COLUMN flow, between groups, not between rows +-- inside one. Groups already carry a 10px margin of their own, so these stack ON +-- TOP of that -- a `section` break reads as 20 between two groups, a `block` as +-- 30. +-- +-- The audit found 58 AddSpace calls passing 9 different numbers, which looked +-- worse than it was: classified by INTENT rather than by value, two idioms cover +-- 42 of them and both were already internally consistent -- +-- +-- section break (after `currentSection = nil`, or a bare gap after a group) +-- 19/19 at 10, plus 7 more following an Add() +-- before the See-Also links at the foot of a page +-- 15/16 at 20, one stray 15 +-- +-- The real inconsistency was three files each picking their own number for the +-- SAME intent (NicknamesPage used 12 throughout), not 9 competing rhythms. +GUI.Space = { + section = 10, -- between logical sections in a page column + block = 20, -- before a distinct trailing block (the See-Also links) + footer = 12, -- below the See-Also bar when it is parked at the viewport bottom +} + +GUI.RowHeight = { + checkbox = 35, -- 2.9 top inset + 18.2 content + RowGap + slider = 46, -- 32.0 content + RowGap (was 55: a slot sized for the dropdown) + dropdown = 54, -- 39.8 content + RowGap + colorpicker = 38, -- 23.9 content + RowGap + editbox = 53, -- 39.0 content + RowGap (box at -15, h24) + toggle = 35, -- two-state switch; same content as a checkbox, same row + -- Labels are VARIABLE height (they wrap), so they have no fixed row — but they do + -- have fixed CHROME, which CreateLabel adds to the measured text height: the 5px top + -- inset its FontString sits at, plus the gap below. That gap IS the whole visible + -- space to the next row — LayoutChildren stacks rows flush (y = y - height) and a + -- labelled control (dropdown/slider/editbox) puts its own label at TOPLEFT 0,0 — so a + -- smaller pad reads as a blurb crowding the control it describes. + labelPad = 5 + GUI.RowGap, + -- EVERY section header, collapsible or not. CreateHeader's container is 25 + -- tall with its text pinned to the BOTTOM, so its 11.1px of internal padding + -- all sits ABOVE the text and this slot minus 25 is the entire visible gap + -- below it -- at exactly 25 there is none. + -- + -- The old split was never designed: collapsible groups were handed 25 (no + -- gap) and plain ones 40, across both files, for the same construct. Rather + -- than sweep ~200 call sites, CreateHeader now marks itself fixedRowHeight, + -- so ResolveRowHeight IGNORES the literal a call site passes and every header + -- lands here. That is the same rule the other factory rows already follow. + sectionHeader = 11.1 + 11.9 + GUI.RowGap, -- top inset + text + the gap +} + +-- Resolve the layout slot height for a widget being added to a group/page. Fixed-height widgets +-- own their height (drift-proof); everything else uses the height it was handed, then the widget's +-- own preferred height, then a sane default. +local function ResolveRowHeight(widget, height) + if widget and widget.fixedRowHeight and widget.preferredHeight then + return widget.preferredHeight + end + return height or (widget and widget.preferredHeight) or 55 +end +GUI.ResolveRowHeight = ResolveRowHeight + +-- Sync a widget's slot height into its host SettingsGroup and re-flow. Used by any +-- widget that only learns its true height AFTER construction (a measured label, an +-- info banner): update the group's stored entry, re-lay out the group, then bubble to +-- the page so sibling groups in the same column re-anchor to the group's new bottom. +-- Without the bubble a grown group's backdrop overshoots the next group's anchor and +-- renders as an empty rectangle of backdrop above it. +function GUI:RelayoutHost(widget, slotHeight) + if not widget then return end + local g = widget.settingsGroup + -- RETIRED widgets must not re-flow anything. A measured label arms a next-frame + -- converge; if the page rebuilds first (any Refresh — e.g. flipping the Colours + -- page's Seconds/Percent tabs), that timer still fires against the PREVIOUS build. + -- Its group is by then parented to the trash frame with its anchors cleared, so + -- re-laying it out sizes children off a dead frame, and the parent walk below still + -- reaches the LIVE page and makes it re-lay out mid-flight. IsShown() does not catch + -- this — a frame keeps its own shown flag when an ancestor is hidden — so test the + -- ancestry instead. + local trash = GUI._trashFrame + if trash then + local p = (g or widget) + while p do + if p == trash then return end + p = p:GetParent() + end + end + if g and g.LayoutChildren then + for _, entry in ipairs(g.groupChildren or {}) do + if entry.widget == widget then + entry.height = slotHeight + break + end + end + g:LayoutChildren() + end + local p = (g or widget):GetParent() + while p do + if type(p.RefreshStates) == "function" and p.children then + p:RefreshStates() + return + end + p = p:GetParent() + end +end + +-- "/df debug guiwidth" — width ground truth for the page on screen: every top-level child and +-- every settings-group child with its live width, flagging any that is non-positive or +-- narrower than its group allows. +-- Use it to TELL APART the two causes of truncated label text, which look identical: +-- * a real width fault -> frames show up flagged here; +-- * a stale FontString wrap -> every width reads correct and the count is 0, yet the +-- text is visibly ellipsised (and scrolling the window snaps it back, because that +-- re-renders the string). That is CreateLabel's Reflow case, not a sizing bug. +-- Recorded because the second one cost two wrong fixes before this dump ruled out the first. +function GUI:DebugDumpWidths() + local page = GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] + if not page or not page.children then + DF:Say("GUI width", "no built page on screen", "WARN") + return + end + local content = GUI.contentFrame and GUI.contentFrame:GetWidth() or -1 + local o = DF:Out("GUI Width", ("page '%s'"):format(GUI.CurrentPageName)) + o:Section("Widths") + o:Field("content frame", ("%.0f"):format(content), content > 0 and "GOOD" or "BAD") + o:Field("scroll child", ("%.0f"):format(page.child and page.child:GetWidth() or -1), "NEUTRAL") + + o:Section("Widgets") + local bad = 0 + local function flag(w, limit) + if not w or w <= 0 then bad = bad + 1; return "|cffff4444 <== NON-POSITIVE|r" end + if limit and w < limit - 1 then bad = bad + 1; return "|cffffcc00 <== NARROW|r" end + return "" + end + for i, widget in ipairs(page.children) do + if widget.isSettingsGroup then + local gw = widget:GetWidth() or 0 + local inner = gw - (widget.padding or 10) * 2 + print((" [%d] group w=%.0f inner=%.0f col=%s shown=%s%s") + :format(i, gw, inner, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(gw))) + for j, entry in ipairs(widget.groupChildren or {}) do + local c = entry.widget + local cw = c and c:GetWidth() or 0 + print((" (%d) w=%.0f h=%.0f shown=%s%s") + :format(j, cw, entry.height or -1, tostring(c and c:IsShown()), flag(cw, inner))) + end + else + local w = widget:GetWidth() or 0 + print((" [%d] widget w=%.0f col=%s shown=%s%s") + :format(i, w, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(w))) + end + end + o:Section("Result") + o:Field("suspect frames", bad, bad > 0 and "WARN" or "GOOD") + o:Siblings("guiwidth") +end + +DF.SectionRegistry = DF.SectionRegistry or {} + + +-- Track selected mode +GUI.SelectedMode = "party" + +-- Registry of tabs that should show a "New" badge until opened. +-- Add tab IDs here for new features; the badge auto-hides once viewed. +-- Reset each release cycle to the tabs that are new since the last stable +-- (prior entries are persisted as seen and would otherwise show stale badges). +GUI.NewTabs = { + ["text_designer"] = true, + ["general_nicknames"] = true, +} + +-- Registry of section headers (inside a tab) that should show a "New" badge +-- until the user visits the tab and then navigates away. Keyed by +-- "." so entries are unambiguous across tabs. +-- The badge is created by GUI:AddSectionNewBadge and cleared by SelectTab +-- when the user leaves the owning tab (persisted via seenSections). +GUI.NewSections = { +} + +-- Live-tracked badges pending a "seen" mark, keyed by tabName → { key = badge }. +-- Populated by AddSectionNewBadge, drained by SelectTab on tab leave. +GUI.pendingSectionBadges = {} + +-- Add a gold "New" badge to the right of a section header's text. Returns the +-- badge FontString, or nil if the section isn't registered in NewSections or +-- has already been marked seen. The badge clears (and is persisted as seen) +-- the next time the user navigates away from `tabName`. +function GUI:AddSectionNewBadge(widget, tabName, sectionId) + -- Anchor to whichever label FontString the widget exposes: + -- * CreateHeader containers use `.text` + -- * CreateDropdown containers use `.label` + local anchor = widget and (widget.text or widget.label) + if not anchor or not tabName or not sectionId then return end + local key = tabName .. "." .. sectionId + if not GUI.NewSections[key] then return end + + local seen = DandersFramesDB_v2 and DandersFramesDB_v2.seenSections + and DandersFramesDB_v2.seenSections[key] + if seen then return end + + local badge = widget:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + badge:SetPoint("LEFT", anchor, "RIGHT", 6, 0) + badge:SetText(L["New"]) + badge:SetTextColor(1, 0.82, 0) + + GUI.pendingSectionBadges[tabName] = GUI.pendingSectionBadges[tabName] or {} + GUI.pendingSectionBadges[tabName][key] = badge + return badge +end + +-- Pages that remain fully accessible regardless of whether party or raid +-- mode is disabled via General settings. All other mode-specific tabs +-- are greyed out and non-interactive when viewing a disabled mode. +-- Auto Layouts is intentionally NOT whitelisted: it edits per-profile +-- settings that would have no effect if all frames are disabled. +GUI.AlwaysAccessiblePages = { + ["general_settings"] = true, -- the toggles themselves + ["profiles_manage"] = true, + ["profiles_importexport"] = true, + ["debug_console"] = true, + ["indicators_targetedlist"] = true, + ["indicators_personal_targeted"] = true, +} + +-- Returns true if the given tab should be disabled for the currently +-- selected mode (i.e. the tab is mode-specific and that mode is off). +function GUI:IsTabDisabledForCurrentMode(tabName) + if not tabName then return false end + if GUI.AlwaysAccessiblePages[tabName] then return false end + if GUI.SelectedMode == "party" and DF.db and DF.db.partyEnabled == false then return true end + if GUI.SelectedMode == "raid" and DF.db and DF.db.raidEnabled == false then return true end + return false +end + +-- Walk all registered tabs and update their .disabled flag + visuals +-- based on the current mode and enable flags. Call after mode switches. +function GUI:UpdateTabAvailability() + if not GUI.Tabs then return end + for name, btn in pairs(GUI.Tabs) do + local disabled = GUI:IsTabDisabledForCurrentMode(name) + btn.disabled = disabled + if btn.Text then + if disabled then + btn.Text:SetTextColor(0.45, 0.45, 0.45) + else + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + if disabled and not btn.isActive then + btn:SetBackdropColor(0, 0, 0, 0) + end + end + + -- Refresh the sidebar so party-only tabs (e.g. Visibility) hide/show for + -- the current mode. + if GUI.UpdateTabLayout then GUI:UpdateTabLayout() end + + -- If the active tab just became hidden (party-only while in raid), move to a + -- safe always-present tab so the user isn't left on a hidden/empty page. + if not GUI._redirectingTab and GUI.SelectedMode == "raid" and GUI.CurrentPageName then + local cur = GUI.Tabs[GUI.CurrentPageName] + if cur and cur.partyOnly and GUI.SelectTab then + GUI._redirectingTab = true + GUI.SelectTab("general_settings") + GUI._redirectingTab = false + end + end +end + +-- Track currently open dropdown menu (only one can be open at a time) +S.currentOpenDropdown = nil + +-- Close any currently open dropdown +local function CloseOpenDropdown() + if S.currentOpenDropdown and S.currentOpenDropdown:IsShown() then + S.currentOpenDropdown:Hide() + end + S.currentOpenDropdown = nil +end +P.CloseOpenDropdown = CloseOpenDropdown + +-- Helper to get current theme color +-- The theme colour for an EXPLICIT mode. Use this for any surface that belongs to +-- a mode rather than to whatever page the options window happens to be showing -- +-- movers, pinned containers -- so a raid surface stays orange while the window is +-- on a party page. GetThemeColor() below is the follow-the-window variant. +local function GetThemeColorFor(isRaid) + if isRaid then return C_RAID else return C_ACCENT end +end +GUI.GetThemeColorFor = GetThemeColorFor + +local function GetThemeColor() + return GetThemeColorFor(GUI.SelectedMode == "raid") +end +GUI.GetThemeColor = GetThemeColor + +-- Physical pixels per UI unit, measured from the frame's OWN effective scale. +-- The GUI window carries a user scale (guiScale) on top of UIParent's and is +-- freely resizable, so this is almost never 1 and cannot be read from the +-- addon-wide DF:GetPixelScale (which is relative to UIParent). +local function PixelsPerUnit(frame) + local eff = frame and frame.GetEffectiveScale and frame:GetEffectiveScale() + local _, physH = GetPhysicalScreenSize() + if not (eff and eff > 0 and physH and physH > 0) then return nil end + return eff * physH / 768 +end +P.PixelsPerUnit = PixelsPerUnit + +-- ⚠ THERE IS NO RUNTIME GEOMETRY CORRECTION, AND THERE SHOULD NOT BE ONE. +-- +-- This GUI used to carry a registry that nudged every bordered box onto the +-- pixel grid after the layout had placed it. It is gone, and the reason is +-- structural rather than a bug in the implementation: Lua-side snapping can only +-- correct a frame at REST, and the symptom it was chasing -- a thin border going +-- soft -- is at its worst while the content is MOVING, during a scroll. +-- Correcting mid-scroll does not fix that, it adds a half-pixel jump on top of +-- it, which reads as flicker. It also cost us the button-row drift, where an +-- unordered sweep corrected chained anchors in a different order each pass. +-- +-- Three things replaced it, and between them they are enough: +-- * the LAYOUT picks whole-pixel numbers in the first place (SnapLen below, at +-- the point an offset, width or height is chosen). No runtime cost, nothing +-- to drift, nothing to flicker -- it just picks better numbers. +-- * the CLIP SURFACES are snapped (page viewport insets, the content panel, +-- the nav chain). Those were real bugs: a box clipped by a fractional edge +-- loses part of its border no matter how well the box itself is placed. +-- * the BORDER is drawn as two device pixels of our own texture rather than a +-- one-unit backdrop edge, so it cannot land badly in the first place. See +-- PIXEL BORDER further down -- that is the part that actually solved it. + +-- Round a LENGTH or OFFSET (in UI units) to a whole number of device pixels at +-- the scale `frame` is drawn at. This is the layout-side half of the pixel-grid +-- work, and the half that was missing. +-- +-- A box lands on the grid only if the numbers it was GIVEN were whole pixels. +-- They usually are not: a group's inner width is (its width - 2 * padding), and +-- its padding is 10 UI units, which is a whole number of device pixels only when +-- the GUI is at exactly 1:1 scale. Everywhere else the box's right and bottom +-- edges fall mid-pixel, and anything clipped by them loses part of itself. +-- +-- Snapping at the point the number is CHOSEN fixes that by construction, for +-- everything a layout places, with no per-widget opt-in for anyone to forget. +local function SnapLen(frame, v) + if not v then return v end + local ppu = PixelsPerUnit(frame) + if not ppu then return v end + return math.floor(v * ppu + 0.5) / ppu +end +GUI.SnapLen = SnapLen + +-- SnapLen rounds to the NEAREST device pixel, which can round a width DOWN and +-- clip the text it was measured from. This rounds up instead: for anything whose +-- length comes from a measurement (a button sized to its own label), the width +-- has to be at least what was asked for, and on the grid. +local function SnapLenUp(frame, v) + if not v then return v end + local ppu = PixelsPerUnit(frame) + if not ppu then return v end + return math.ceil(v * ppu) / ppu +end +GUI.SnapLenUp = SnapLenUp + +-- Round a HEIGHT to an EVEN number of device pixels (minimum two). +-- +-- Rows of controls are chained with centre-aligning anchors -- SetPoint("RIGHT", +-- prev, "LEFT", gap, 0) aligns the two frames' vertical CENTRES, and the toolbar +-- does the same with LEFT/RIGHT. A frame's centre is bottom + height/2, so if the +-- height is an ODD number of device pixels the centre falls on a half pixel, and +-- every frame chained off it inherits that half-pixel offset no matter how well +-- its own edges are snapped. Even heights make the whole row land together. +-- +-- Used for control heights, which the factories set once at construction. Widths +-- do not need this: nothing centre-anchors horizontally off a control. +local function SnapHeightEven(frame, v) + if not v then return v end + local ppu = PixelsPerUnit(frame) + if not ppu then return v end + return math.max(2, math.floor(v * ppu / 2 + 0.5) * 2) / ppu +end +GUI.SnapHeightEven = SnapHeightEven + +-- ============================================================ +-- Stamped once when this file loads, so it identifies THIS session (and therefore +-- this build) for the pixelcheck and gapcheck captures. Declared HERE, above the +-- first function that reads it: a local declared further down the file is not an +-- upvalue for a function defined above it -- the read would silently resolve to a +-- nil global and every capture would look like a new session. +local GAP_SESSION = date and date("%Y-%m-%d %H:%M:%S") or "?" + +-- /df debug pixelcheck -- measure, don't guess +-- +-- The "top border of a box goes missing until you scroll" bug has now been +-- diagnosed twice from reasoning about the layout and fixed twice, and it is +-- still here. The two remaining explanations look IDENTICAL in a screenshot: +-- +-- (a) sub-pixel -- the box's top edge lands between two device rows, so the +-- 1px line is filtered across both at half intensity; +-- (b) clipping -- the box's top edge sits within a pixel of the ScrollFrame's +-- own clip boundary, so the line is simply cut off. +-- +-- Scrolling "fixes" both, which is exactly why the screenshot can't separate +-- them. This reports the numbers for the open page so the next change is aimed. +-- Debug output: deliberately raw, like the other /df debug dumps. +-- ============================================================ + +-- Signed distance from `v` (UI units) to the nearest whole device pixel. +local function PixelOffsetOf(v, ppu) + if not v or not ppu then return nil end + local px = v * ppu + return px - math.floor(px + 0.5) +end + +local function DescribeFrame(f) + -- Prefer a human label so the output names the card, not "Frame". + for _, key in ipairs({ "label", "title", "titleText", "Text", "header" }) do + local o = f[key] + if type(o) == "table" and o.GetText then + local t = o:GetText() + if t and t ~= "" then return t end + end + end + if f.GetRegions then + for _, r in ipairs({ f:GetRegions() }) do + if r.GetObjectType and r:GetObjectType() == "FontString" then + local t = r:GetText() + if t and t ~= "" then return t end + end + end + end + return (f.GetObjectType and f:GetObjectType()) or "Frame" +end + +-- Every SHOWN descendant carrying a border -- the only frames that can exhibit +-- this bug (a fill-only surface has no edge to lose). +-- +-- Both mechanisms, deliberately. Testing edgeFile alone was right when that was +-- the only way a border got drawn; now that most surfaces own their four +-- textures instead, an edgeFile-only sweep would report a converted page as +-- having no bordered frames at all and the probe would quietly stop being able +-- to see the thing it exists to measure. +local function CollectBorderedFrames(root, out, depth) + if not root or depth > 10 then return out end + for _, child in ipairs({ root:GetChildren() }) do + if child:IsShown() then + local bd = child.GetBackdrop and child:GetBackdrop() + if (bd and bd.edgeFile) or child._pxBorder then out[#out + 1] = child end + CollectBorderedFrames(child, out, depth + 1) + end + end + return out +end + +-- What sits ON the box's top border row. +-- +-- Geometry came back perfect on BOTH a broken and a working page (BOX 0/n, +-- top+0.00, edge=1.00px, nothing near the clip edge), so the difference is not +-- anything the box itself measures. The one thing that tracked the symptom was +-- how far the first box sits from the top: 62.7px on a page that loses its +-- border vs 76.8px on one that doesn't -- 14.1px apart, which at 1.4062 px/unit +-- is exactly the 10-unit group padding. A box that starts hard against whatever +-- is above it loses the line; one with a gap keeps it. That is the signature of +-- the row above covering it, so: find any sibling whose rect spans the box's top +-- border row, and report it with its frame level. +local function FindTopRowOverlaps(box) + local out = {} + local top, bL, bR = box:GetTop(), box:GetLeft(), box:GetRight() + local parent = box:GetParent() + if not (top and bL and bR and parent) then return out end + -- ~1.5 device px expressed in UI units: the border row plus a hair. + local band = 1.5 / (PixelsPerUnit(box) or 1) + for _, sib in ipairs({ parent:GetChildren() }) do + if sib ~= box and sib.IsShown and sib:IsShown() then + local sT, sB, sL, sR = sib:GetTop(), sib:GetBottom(), sib:GetLeft(), sib:GetRight() + if sT and sB and sL and sR + and sB <= top + band and sT >= top - band -- spans the border row + and sR > bL and sL < bR then -- and overlaps horizontally + out[#out + 1] = ("%s(lvl%d)"):format( + DescribeFrame(sib):sub(1, 16), sib:GetFrameLevel() or 0) + end + end + end + return out +end + +function GUI.PixelCheck() + local page, pageName + for name, p in pairs(GUI.Pages or {}) do + if p.IsShown and p:IsShown() then page, pageName = p, name; break end + end + if not page then + DF:Say("Pixel check", "no settings page is open", "WARN") + return + end + + local ppu = PixelsPerUnit(page) + if not ppu then + DF:Say("Pixel check", "scale unresolved — is the window shown?", "WARN") + return + end + + local scroll = page.GetVerticalScroll and page:GetVerticalScroll() or 0 + local scrollOff = PixelOffsetOf(scroll, ppu) + local clipTop = page:GetTop() + + local o = DF:Out("Pixel Check", ("page %s"):format(tostring(pageName))) + o:Section("Scale") + o:Field("effective scale", ("%.4f"):format(page:GetEffectiveScale() or 0), "NEUTRAL") + o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") + + o:Section("Scroll") + -- Reported, not judged. This used to print a red NOT SNAPPED when the offset + -- was off-grid, back when the scroll offset was quantised and being off-grid + -- meant something had gone wrong. Nothing quantises it now -- a 2px border + -- draws the same ink at any offset -- so an off-grid figure here is the + -- normal state of a scrolled page, and flagging it as a fault sends the next + -- person reading this output after a bug that is not there. + o:Field("offset", ("%.3f (%.2f px off grid)"):format(scroll, scrollOff or 0), "NEUTRAL") + o:Line("Off-grid here is expected — nothing quantises the scroll offset.", "NEUTRAL") + + -- THE CLIP BOUNDARY ITSELF. Earlier runs measured each box's DISTANCE to this + -- edge but never whether the edge is on-grid. A ScrollFrame clips to its own + -- rect, so if that rect's top sits on a fractional device row the cut takes a + -- partial row off whatever is nearest it -- which is exactly the reported + -- pattern: pages whose boxes start at the top lose the border, pages with a + -- gap do not. The scroll child is included because content is positioned + -- against it, so its phase is what every box inherits. + local dPageTop = PixelOffsetOf(page:GetTop(), ppu) + local dPageBot = PixelOffsetOf(page:GetBottom(), ppu) + local dPageH = PixelOffsetOf(page:GetHeight(), ppu) + -- Flag EITHER edge. This used to test only the top, so it printed + -- "bot-0.38" on every run for weeks and never once marked it -- and an + -- unflagged number in a wall of numbers is an invisible one. The bottom + -- edge clips whatever rests against it just as hard as the top does, which + -- is the entire See-Also footer bug. + local badTop = dPageTop and math.abs(dPageTop) > 0.05 + local badBot = dPageBot and math.abs(dPageBot) > 0.05 + o:Section("Viewport", "the clip edge") + o:Field("offsets", ("top%+.2f bot%+.2f h%+.2f"):format(dPageTop or 0, dPageBot or 0, dPageH or 0), + (badTop or badBot) and "BAD" or "GOOD") + if badTop or badBot then + o:Line(("Clip edge OFF-GRID (%s) — it shaves a partial row off whatever rests against it."):format( + badTop and (badBot and "top and bottom" or "top") or "bottom"), "BAD") + end + local kid = page.child or (page.GetScrollChild and page:GetScrollChild()) + if kid then + local kppu = PixelsPerUnit(kid) or ppu + local kidBad = math.abs(PixelOffsetOf(kid:GetTop(), kppu) or 0) > 0.05 + o:Field("scroll child", ("top%+.2f w%+.2f"):format( + PixelOffsetOf(kid:GetTop(), kppu) or 0, PixelOffsetOf(kid:GetWidth(), kppu) or 0), + kidBad and "BAD" or "GOOD") + if kidBad then + o:Line("Content is positioned against this, so every box inherits its phase.", "BAD") + end + end + + -- "Is it the section BOXES or the controls inside them?" is the question the + -- first version of this could not answer -- it ranked worst-first and the top + -- of the list was all controls, so the groups never showed. Classify, and + -- report the boxes separately no matter where they rank. + local function KindOf(f) + if f.LayoutChildren then return "BOX" end -- CreateSettingsGroup + if f.slider then return "slider" end + return (f.GetObjectType and f:GetObjectType()) or "frame" + end + + local frames = CollectBorderedFrames(page.child or page, {}, 0) + local rows, offGrid, nearClip = {}, 0, 0 + local byKind = {} + for _, f in ipairs(frames) do + local top, bottom, h = f:GetTop(), f:GetBottom(), f:GetHeight() + local fppu = PixelsPerUnit(f) or ppu + local dTop = PixelOffsetOf(top, fppu) + local dBot = PixelOffsetOf(bottom, fppu) + local dH = PixelOffsetOf(h, fppu) + -- Distance from the scroll viewport's top clip edge, in device pixels. + local clipGap = (top and clipTop) and ((clipTop - top) * fppu) or nil + if dTop and math.abs(dTop) > 0.05 then offGrid = offGrid + 1 end + if clipGap and clipGap > -1.5 and clipGap < 1.5 then nearClip = nearClip + 1 end + -- Edge THICKNESS, which is the leg two earlier revisions of this probe + -- did not capture and the one that turned out to matter. A box can + -- measure a perfect 0.00 on every edge and still lose its border if the + -- edge is drawn a fractional number of device pixels wide: it bleeds + -- into the next row at partial intensity, and a settings-group edge is + -- only ~8% alpha over a 3% fill, so both halves can land under the + -- visibility floor. Alpha is reported alongside because it sets that + -- floor. + -- + -- A pixel border reports its own thickness instead: it is authored in + -- device pixels, so it is a whole number by construction and edgeFrac + -- is 0 -- which is exactly the point of it, and worth being able to SEE + -- next to a surface still on the old edge. + local bdInfo = f.GetBackdrop and f:GetBackdrop() + local edgeUnits = bdInfo and bdInfo.edgeSize or nil + -- f._pxDevicePx, not a recomputation from PX_BORDER_THICKNESS: that + -- constant is declared several hundred lines below this probe, so + -- naming it here would resolve to a nil GLOBAL, silently. Reading what + -- LayoutPixelBorder actually drew is both safer and more truthful. + local edgePx = edgeUnits and (edgeUnits * fppu) or f._pxDevicePx or nil + local edgeFrac = edgePx and (edgePx - math.floor(edgePx + 0.5)) or nil + -- NOT `local _,_,_,a = (f:GetBackdropBorderColor())` -- the parentheses + -- truncate a multi-return to ONE value, so alpha came back nil every time + -- and every row printed "a=n/a". Alpha is the whole point here: a + -- settings-group edge is ~8% over a 3% fill, so it has almost no margin. + local borderA + if f.GetBackdropBorderColor then + local _, _, _, a = f:GetBackdropBorderColor() + borderA = a + end + local kind = KindOf(f) + local bad = (dTop and math.abs(dTop) > 0.05) or false + local k = byKind[kind] + if not k then k = { n = 0, bad = 0 }; byKind[kind] = k end + k.n = k.n + 1 + if bad then k.bad = k.bad + 1 end + rows[#rows + 1] = { + label = DescribeFrame(f), kind = kind, bad = bad, + frame = f, level = f.GetFrameLevel and f:GetFrameLevel() or nil, + dTop = dTop or 0, dBot = dBot or 0, + dH = dH or 0, clipGap = clipGap, + edgePx = edgePx, edgeFrac = edgeFrac, alpha = borderA, + score = math.max(math.abs(dTop or 0), + (clipGap and math.abs(clipGap) < 1.5) and 1 or 0), + } + end + + table.sort(rows, function(a, b) return a.score > b.score end) + o:Section("Bordered frames", #rows) + o:Field("off-grid top", offGrid, offGrid > 0 and "BAD" or "GOOD") + o:Field("within 1px of the clip edge", nearClip, nearClip > 0 and "WARN" or "GOOD") + + -- Per-kind tally: this is the line that says whether fixing controls would + -- also fix the section boxes, or whether they are a separate problem. + local kindLine = {} + for kind, k in pairs(byKind) do + kindLine[#kindLine + 1] = ("%s %d/%d"):format(kind, k.bad, k.n) + end + table.sort(kindLine) + o:Field("by kind (bad/total)", table.concat(kindLine, " "), "NEUTRAL") + + local function emit(r) + local flag = "" + if r.bad then flag = " |cffff6060OFF-GRID|r" end + if r.clipGap and math.abs(r.clipGap) < 1.5 then flag = flag .. " |cffffaa00AT-CLIP|r" end + -- A fractional edge width is the failure a perfect 0.00 box can still have. + if r.edgeFrac and math.abs(r.edgeFrac) > 0.05 then flag = flag .. " |cffff6060SOFT-EDGE|r" end + if r.alpha and r.alpha < 0.15 then flag = flag .. " |cffffaa00FAINT|r" end + print((" [%s] %-22s top%+.2f bot%+.2f h%+.2f edge=%s a=%s clip=%s%s"):format( + r.kind:sub(1, 6), r.label:sub(1, 22), r.dTop, r.dBot, r.dH, + r.edgePx and ("%.2fpx"):format(r.edgePx) or "n/a", + r.alpha and ("%.2f"):format(r.alpha) or "n/a", + r.clipGap and ("%.1f"):format(r.clipGap) or "n/a", flag)) + end + + -- The section boxes ALWAYS get listed, however they rank -- they are the ones + -- you can actually see, and ranking buried them last time. + local boxes = 0 + for _, r in ipairs(rows) do if r.kind == "BOX" then boxes = boxes + 1 end end + if boxes > 0 then + o:Section("Section boxes", boxes .. " — the outlines around each section") + local shown = 0 + for _, r in ipairs(rows) do + if r.kind == "BOX" and shown < 10 then + emit(r) + -- Anything sitting ON this box's top border row is the prime + -- suspect now that the box's own geometry measures clean. + local over = r.frame and FindTopRowOverlaps(r.frame) or {} + if #over > 0 then + print((" |cffff6060^ COVERED BY:|r %s (box is lvl%s)") + :format(table.concat(over, ", "), tostring(r.level))) + end + shown = shown + 1 + end + end + else + o:Section("Section boxes") + o:Line("None found on this page.", "WARN") + end + + o:Section("Worst overall") + o:Line("topOff/botOff/heightOff are px from the grid; clip is px below the viewport top.", "NEUTRAL") + for i = 1, math.min(#rows, 10) do emit(rows[i]) end + print(" |cff808080Read: OFF-GRID = geometry. SOFT-EDGE = the edge is a fractional number of device px wide, so it bleeds into the next row -- a box can be a perfect 0.00 and still lose its border this way. FAINT = so little alpha that any split is invisible.|r") + print(" |cff808080Nothing is corrected at runtime any more. Every widget gets its whole-pixel numbers from its FACTORY at construction (nudging them afterwards is what made chained button rows drift), so an off-grid row after a scale change is expected and is not a bug. What still matters here is SOFT-EDGE and FAINT.|r") + + -- Persist, for the same reason gapcheck does: transcribing a screenful of + -- numbers out of the chat frame is not practical remotely, and every reading + -- of this symptom that came from eyeballing rather than the file has been + -- wrong. Same session stamp, so a capture never mixes two builds. + DandersFramesDebugDB = DandersFramesDebugDB or {} + if DandersFramesDebugDB.pixelSession ~= GAP_SESSION then + DandersFramesDebugDB.pixelcheck = nil + DandersFramesDebugDB.pixelSession = GAP_SESSION + end + DandersFramesDebugDB.pixelcheck = DandersFramesDebugDB.pixelcheck or {} + local pdump = { + when = date("%Y-%m-%d %H:%M:%S"), + ppu = ppu, scroll = scroll, + viewTop = dPageTop, viewBot = dPageBot, viewH = dPageH, + rows = {}, + } + for i, r in ipairs(rows) do + pdump.rows[i] = { + label = tostring(r.label):sub(1, 40), kind = r.kind, + dTop = r.dTop, dBot = r.dBot, dH = r.dH, + edgePx = r.edgePx, alpha = r.alpha, clipGap = r.clipGap, + level = r.level, + -- Raw geometry too: the deltas alone cannot distinguish "off-grid" + -- from "the right size but drawn somewhere unexpected". + top = r.frame and r.frame:GetTop() or nil, + bottom = r.frame and r.frame:GetBottom() or nil, + height = r.frame and r.frame:GetHeight() or nil, + width = r.frame and r.frame:GetWidth() or nil, + points = r.frame and r.frame:GetNumPoints() or nil, + } + end + -- Never overwrite an earlier run of the SAME page: the whole point of running + -- this twice is to compare a broken state against a working one, and keying + -- purely by page silently threw the first away. Numbered within the session. + local key, n = tostring(pageName), 1 + while DandersFramesDebugDB.pixelcheck[key] do + n = n + 1 + key = ("%s #%d"):format(tostring(pageName), n) + end + DandersFramesDebugDB.pixelcheck[key] = pdump + print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.pixelcheck[\"%s\"] -- |cffffcc00/reload to flush.|r") + :format(#rows, key)) +end + +-- ============================================================ +-- /df debug navprobe -- catch the left-nav hover flash in the act +-- +-- The symptom: sweeping the cursor down the nav list shows a "ghost" -- of the +-- row's text, or of the bottom part of the hover plate. It happens at moderate +-- speed, not just fast, and it survived snapping the row geometry. +-- +-- Four causes would produce that, and they are INDISTINGUISHABLE in a +-- screenshot, which is why this measures instead of reasoning: +-- +-- (a) two rows lit at once -- the previous row's OnLeave never ran, so two +-- plates are visible together for a frame or two; +-- (b) focus thrash -- the cursor sits still over one row but mouse focus +-- alternates between it and something else (the scroll frame, a sibling, +-- an overlapping rect), so the plate flickers on and off in place; +-- (c) geometry -- rows overlap, or leave a dead band between them where +-- NOTHING is lit, so crossing it reads as the plate breaking up; +-- (d) none of the above -- a pure rendering artefact, in which case the trace +-- shows exactly one clean enter/leave per row and the answer is elsewhere. +-- +-- Static geometry first (overlaps and dead bands are visible without moving the +-- mouse), then a live trace of every change in focus and in each row's plate +-- alpha, stamped with the frame number. (a) and (b) show up as repeated +-- transitions within a single crossing; (c) as a run of frames with nothing lit. +function GUI.NavProbe(seconds) + local container = GUI.tabContainer + if not (container and container:IsVisible()) then + DF:Say("the settings window is not open.") + return + end + local ppu = PixelsPerUnit(container) or 1 + + -- Rows in LAYOUT order (top to bottom), which is what makes the neighbour + -- comparison below meaningful -- GetChildren order is creation order. + local rows = {} + for _, catName in ipairs(GUI.CategoryOrder or {}) do + local cat = GUI.Categories and GUI.Categories[catName] + if cat and cat:IsShown() then + rows[#rows + 1] = { f = cat, label = "[" .. tostring(catName) .. "]" } + for _, btn in ipairs(cat.children or {}) do + if btn:IsShown() then + rows[#rows + 1] = { f = btn, label = DescribeFrame(btn) } + end + end + end + end + + local o = DF:Out("Nav Probe") + o:Section("Rows") + o:Field("visible", #rows, #rows > 0 and "GOOD" or "WARN") + o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") + + -- The ANCESTOR CHAIN, because a row cannot be on the grid if the frame it + -- hangs off is not: every ancestor here is two-corner anchored, so nothing + -- corrects them after the fact and a fraction anywhere propagates to all 42 + -- rows identically. If the rows read a uniform offset, this line says which + -- link introduced it -- that is how the 4-unit nav inset (0.375px) was found + -- after the rows themselves measured clean on height. + local chain, node = {}, container + while node and #chain < 6 do + local t = node:GetTop() + chain[#chain + 1] = ("%s top%+.2f"):format( + (node.GetObjectType and node:GetObjectType() or "?"):sub(1, 6), + PixelOffsetOf(t, PixelsPerUnit(node) or ppu) or 0) + node = node:GetParent() + end + print(" chain (row -> window): " .. table.concat(chain, " | ")) + + -- Geometry: the gap to the row above, in DEVICE pixels. Negative = the rows + -- overlap (both can claim the cursor); more than ~1px positive = a dead band + -- with no row under the cursor at all. Either one produces a visible break. + local overlaps, bands = 0, 0 + for i, r in ipairs(rows) do + local f = r.f + local top, bot, h = f:GetTop(), f:GetBottom(), f:GetHeight() + local dTop = PixelOffsetOf(top, ppu) or 0 + local dH = PixelOffsetOf(h, ppu) or 0 + local gap + if i > 1 then + local prevBot = rows[i - 1].f:GetBottom() + if prevBot and top then gap = (prevBot - top) * ppu end + end + local flag = "" + if gap and gap < -0.05 then flag = " |cffff6060OVERLAPS ABOVE|r"; overlaps = overlaps + 1 + elseif gap and gap > 1.05 then flag = " |cffffaa00DEAD BAND|r"; bands = bands + 1 end + if math.abs(dTop) > 0.05 or math.abs(dH) > 0.05 then + flag = flag .. " |cffffaa00OFF-GRID|r" + end + print((" %-24s top%+.2f h%+.2f gap=%s lvl%d%s"):format( + r.label:sub(1, 24), dTop, dH, + gap and ("%.2fpx"):format(gap) or "n/a", + f:GetFrameLevel() or 0, flag)) + end + print((" %d overlapping rows, %d dead bands between rows"):format(overlaps, bands)) + + -- Live trace. + S.navTrace = S.navTrace or CreateFrame("Frame") + S.navTrace:SetScript("OnUpdate", nil) + local dur = tonumber(seconds) or 8 + local elapsed, frames, events = 0, 0, 0 + local lastLit, lastFocus = nil, nil + print((" |cff00ff00tracing for %ds|r -- sweep the cursor across the nav list now."):format(dur)) + + S.navTrace:SetScript("OnUpdate", function(_, dt) + elapsed = elapsed + dt + frames = frames + 1 + + -- Which row the shared plate is parked on. There is only one plate now, so + -- "TWO LIT" is structurally impossible -- that is the point of the change, + -- and this still checks for it in case the plate is ever reintroduced + -- per-row. No parentheses around the colour call: they would truncate the + -- multi-return to one value and alpha would read nil every frame -- the + -- exact mistake that cost three rounds on the border bug. + local lit = {} + local hl = GUI.navHover + if hl and hl:IsShown() and hl.owner then + local _, _, _, a = hl:GetBackdropColor() + if a and a > 0.01 then + for _, r in ipairs(rows) do + if r.f == hl.owner then lit[#lit + 1] = r.label:sub(1, 18) break end + end + end + end + local litKey = table.concat(lit, "+") + + -- What actually owns the mouse. If this is NOT the lit row, the plate and + -- the focus disagree, which is cause (b). + local focus = "-" + local foci = GetMouseFoci and GetMouseFoci() + local top = (foci and foci[1]) or (GetMouseFocus and GetMouseFocus()) + if top then + for _, r in ipairs(rows) do if r.f == top then focus = r.label:sub(1, 18) break end end + if focus == "-" then + focus = "<" .. ((top.GetObjectType and top:GetObjectType()) or "?") .. ">" + end + end + + if litKey ~= lastLit or focus ~= lastFocus then + events = events + 1 + if events <= 120 then + print((" f%-5d t=%.3f lit=%-24s focus=%s%s"):format( + frames, elapsed, + (litKey ~= "" and litKey or "(none)"), + focus, + (#lit > 1) and " |cffff6060TWO LIT|r" + or ((litKey == "" and focus ~= "-") and " |cffffaa00FOCUS BUT UNLIT|r" or ""))) + end + lastLit, lastFocus = litKey, focus + end + + if elapsed >= dur then + S.navTrace:SetScript("OnUpdate", nil) + print((" |cff00ff00navprobe done|r -- %d frames, %d state changes%s"):format( + frames, events, events > 120 and " (first 120 shown)" or "")) + print(" |cff808080Read: one enter + one leave per row = clean, look elsewhere. Repeated flips inside one crossing = focus thrash. TWO LIT = a stale plate. lit=(none) with focus on a row = the handler did not fire.|r") + end + end) +end + +-- ============================================================ +-- /df debug gapcheck -- measure the vertical rhythm, don't eyeball it +-- +-- The question this answers: "which rows are too far apart, and which are too +-- close?" It cannot be answered from GUI.RowHeight alone, because those numbers +-- are SLOT heights, not gaps. LayoutChildren stacks rows flush (y = y - height), +-- so the visible gap between two rows is: +-- +-- (slot height - content bottom) of the row above +-- + (slot top - content top) of the row below +-- +-- A row whose content is short inside a tall slot gets a big gap and nothing +-- flags it. The slider is the worst case by construction: it shares the 55 slot +-- with the dropdown and edit box, whose openers reach ~40, while a slider only +-- draws to ~32 (label, an 8px track, a 20px value box) -- so it carries ~23px of +-- slack against their ~15. +-- +-- Measured, not derived, for a reason: labels WRAP, override markers hang off +-- rows, banners re-measure themselves after construction, and hideOn rows drop +-- out. Only the live rects know the real content extent, which is exactly the +-- lesson from the border bug -- the arithmetic looked right there too. +-- +-- Rows are compared only against their SIBLINGS IN THE SAME GROUP. That is where +-- the rhythm actually reads, and it sidesteps having to reconstruct which column +-- a widget landed in. +local function ContentExtent(f) + local top, bottom + local function acc(o) + if not o or not o.IsShown or not o:IsShown() then return end + -- Skip things that draw NOTHING: an empty label or a fully transparent + -- placeholder still has a rect, and counting it would inflate the content + -- and hide the very slack we are looking for. + -- + -- FontStrings ONLY. An EditBox also answers GetText, and an empty one + -- would have been skipped here even though its box is plainly drawn -- + -- which would have under-measured every blank input on the page. + if o.GetObjectType and o:GetObjectType() == "FontString" then + local s = o:GetText() + if s == nil or s == "" then return end + end + if o.GetAlpha and (o:GetAlpha() or 1) <= 0.01 then return end + local t, b = o:GetTop(), o:GetBottom() + if t and b then + top = (top and math.max(top, t)) or t + bottom = (bottom and math.min(bottom, b)) or b + end + end + if f.GetRegions then for _, r in ipairs({ f:GetRegions() }) do acc(r) end end + if f.GetChildren then for _, c in ipairs({ f:GetChildren() }) do acc(c) end end + return top, bottom +end + +-- Every SHOWN SettingsGroup under the page, at any depth (the Aura Designer nests +-- its groups inside cards). +local function CollectGroups(root, out, depth) + if not root or (depth or 0) > 8 then return out end + if root.groupChildren and root.IsShown and root:IsShown() then out[#out + 1] = root end + if root.GetChildren then + for _, c in ipairs({ root:GetChildren() }) do + if c.IsShown and c:IsShown() then CollectGroups(c, out, (depth or 0) + 1) end + end + end + return out +end + +function GUI.GapCheck(mode) + if mode == "clear" then + DandersFramesDebugDB = DandersFramesDebugDB or {} + DandersFramesDebugDB.gapcheck = nil + DF:Say("saved capture cleared (/reload to flush).") + return + end + + local page, pageName + for name, p in pairs(GUI.Pages or {}) do + if p.IsShown and p:IsShown() then page, pageName = p, name break end + end + if not page then + DF:Say("Gap check", "no settings page is open", "WARN") + return + end + + local groups = CollectGroups(page.child or page, {}, 0) + local rows, byKind, pairs_, nRows = {}, {}, {}, 0 + + for _, group in ipairs(groups) do + local prev + for _, entry in ipairs(group.groupChildren or {}) do + local w = entry.widget + if w and w.IsShown and w:IsShown() and entry.height then + local slotTop = w:GetTop() + local cTop, cBot = ContentExtent(w) + if slotTop and cTop and cBot then + -- The slot is entry.height from the widget's TOP -- NOT the + -- widget's own rect. LayoutChildren only sets TOPLEFT (and + -- width), so a container constructed at 50 sitting in a 55 + -- slot would under-report by 5 if we used GetBottom(). + local slotBot = slotTop - entry.height + local kind = w.rowKind or (w.LayoutChildren and "group") + or (w.GetObjectType and w:GetObjectType()) or "?" + local r = { + kind = kind, + label = (w.GetText and w:GetText()) or (w.Text and w.Text.GetText and w.Text:GetText()) or kind, + slot = entry.height, + content = cTop - cBot, + padTop = slotTop - cTop, + padBot = cBot - slotBot, + -- Absolute edges, because the gap has to be measured from + -- where the rows LANDED, not from entry.height. The layout + -- can shorten a row after the fact (the compact-run + -- tightening does exactly that), and slot arithmetic + -- cannot see it -- the first version of this reported the + -- untightened number and made the feature look inert. + cTop = cTop, + cBot = cBot, + -- The slot's own top, so the height the layout ACTUALLY + -- used is derivable offline (prev.slotTop - this.slotTop) + -- and can be compared against entry.height. + slotTop = slotTop, + tight = w._rowTightened or false, + nextKind = w._rowNextKind, + } + nRows = nRows + 1 + rows[#rows + 1] = r + + local k = byKind[kind] + if not k then k = { n = 0, slot = 0, content = 0, padTop = 0, padBot = 0 } byKind[kind] = k end + k.n, k.slot, k.content = k.n + 1, k.slot + r.slot, k.content + r.content + k.padTop, k.padBot = k.padTop + r.padTop, k.padBot + r.padBot + + -- The gap the eye actually sees: the distance between where + -- the previous row's content ENDED and this one's STARTS, + -- straight off the resolved rects. + if prev then + local gap = prev.cBot - r.cTop + local key = ("%s -> %s"):format(prev.kind, kind) + local p = pairs_[key] + if not p then p = { n = 0, sum = 0, min = gap, max = gap } pairs_[key] = p end + p.n, p.sum = p.n + 1, p.sum + gap + p.min, p.max = math.min(p.min, gap), math.max(p.max, gap) + end + prev = r + end + end + end + end + + if nRows == 0 then + local o = DF:Out("Gap Check", "page " .. tostring(pageName)) + o:Line("No measurable rows — is everything collapsed?", "WARN") + o:Siblings("gapcheck") + return + end + + local o = DF:Out("Gap Check", "page " .. tostring(pageName)) + o:Section("Measured", "UI units") + o:Field("groups", #groups, "NEUTRAL") + o:Field("rows", nRows, "NEUTRAL") + + o:Section("Per kind") + o:Line("slot is what RowHeight hands out; content is what it actually draws.", "NEUTRAL") + local kinds = {} + for kind in pairs(byKind) do kinds[#kinds + 1] = kind end + table.sort(kinds, function(a, b) return (byKind[a].padBot / byKind[a].n) > (byKind[b].padBot / byKind[b].n) end) + for _, kind in ipairs(kinds) do + local k = byKind[kind] + print((" %-12s n=%-3d slot %5.1f content %5.1f padTop %4.1f |cffffcc00padBottom %4.1f|r") + :format(kind, k.n, k.slot / k.n, k.content / k.n, k.padTop / k.n, k.padBot / k.n)) + end + + -- Did the compact-run tightening actually fire? The gap alone cannot say -- + -- it only shows the result -- and reading the source said it should while the + -- measurement said it had not. So count the decision directly, and when a run + -- did NOT close up, name the kind that broke it. + local tightened, compactRows, breakers = 0, 0, {} + for _, r in ipairs(rows) do + if GUI.RowCompact[r.kind] then + compactRows = compactRows + 1 + if r.tight then + tightened = tightened + 1 + elseif r.nextKind then + breakers[r.nextKind] = (breakers[r.nextKind] or 0) + 1 + end + end + end + if compactRows > 0 then + local why = {} + for k, n in pairs(breakers) do why[#why + 1] = ("%s x%d"):format(k, n) end + table.sort(why) + print((" compact-run tightening: |cffffcc00%d/%d|r compact rows closed up%s") + :format(tightened, compactRows, + #why > 0 and (" |cff808080(run broken by: %s)|r"):format(table.concat(why, ", ")) or "")) + end + + print(" gaps between stacked rows (padBottom above + padTop below) -- widest first:") + local keys = {} + for key in pairs(pairs_) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return (pairs_[a].sum / pairs_[a].n) > (pairs_[b].sum / pairs_[b].n) end) + for _, key in ipairs(keys) do + local p = pairs_[key] + local avg = p.sum / p.n + -- A pair whose min and max differ is NOT a spacing constant -- something + -- (a wrapped label, a hand-rolled AddSpace) is varying it, and averaging + -- would hide exactly that. + local spread = (p.max - p.min > 0.5) + and (" |cffff6060varies %.1f..%.1f|r"):format(p.min, p.max) or "" + print((" %6.1f %-26s x%d%s"):format(avg, key, p.n, spread)) + end + + if mode == "all" then + print(" every row, in layout order:") + for _, r in ipairs(rows) do + print((" %-12s %-22s slot %5.1f content %5.1f padTop %4.1f padBottom %4.1f") + :format(r.kind, tostring(r.label):sub(1, 22), r.slot, r.content, r.padTop, r.padBot)) + end + end + + -- Persist the RAW rows to SavedVariables. Reading a hundred rows out of the + -- chat frame is not practical, and transcribing them by hand would introduce + -- exactly the kind of error this probe exists to avoid. + -- + -- Keyed BY PAGE so visiting several pages accumulates one dataset instead of + -- each run overwriting the last -- walk the pages you care about, then + -- /reload ONCE. SavedVariables are only flushed on logout or reload, so a run + -- that is never followed by one is never written. + -- + -- Its own saved variable, not a corner of DandersFramesDB_v2: diagnostics + -- must not sit inside the profile DB, where they would ride along with every + -- export and show up in the export audit. + DandersFramesDebugDB = DandersFramesDebugDB or {} + DandersFramesDebugDB.gapcheck = DandersFramesDebugDB.gapcheck or {} + -- Drop everything from a PREVIOUS session first. Accumulating by page is what + -- makes a multi-page capture possible, but it also means a page you did not + -- revisit after a code change keeps its stale rows -- and mixing two builds + -- invents variance that is not in either of them. (It already cost one wrong + -- diagnosis: three stale pages made the row heights look like they had not + -- applied at all.) One /reload = one dataset. + if DandersFramesDebugDB.gapSession ~= GAP_SESSION then + wipe(DandersFramesDebugDB.gapcheck) + DandersFramesDebugDB.gapSession = GAP_SESSION + end + local dump = { when = date("%Y-%m-%d %H:%M:%S"), rows = {} } + if page.GetEffectiveScale then dump.scale = page:GetEffectiveScale() end + for i, r in ipairs(rows) do + dump.rows[i] = { + kind = r.kind, label = tostring(r.label):sub(1, 40), + slot = r.slot, content = r.content, + padTop = r.padTop, padBot = r.padBot, + cTop = r.cTop, cBot = r.cBot, -- so gaps can be re-derived offline + slotTop = r.slotTop, tight = r.tight, nextKind = r.nextKind, + } + end + DandersFramesDebugDB.gapcheck[tostring(pageName)] = dump + + local nPages = 0 + for _ in pairs(DandersFramesDebugDB.gapcheck) do nPages = nPages + 1 end + print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.gapcheck[\"%s\"] -- %d page(s) captured. |cffffcc00Visit the pages you care about, then /reload to flush.|r") + :format(#rows, tostring(pageName), nPages)) + print(" |cff808080Read: padBottom is the slack under a row -- the knob is GUI.RowHeight[kind], and slot - content IS that slack. A kind sorting to the top of the first list is over-spaced; one near zero is cramped. In the second list, 'varies' means the gap is not coming from RowHeight alone. Add 'all' for every row, 'clear' to wipe the saved capture.|r") +end +-- (Removed) GUI.GapCheckAll — a no-arg alias for GapCheck("all"), superseded once the +-- dispatcher started forwarding the mode argument. Zero callers. + +-- Every frame that carries a 1px backdrop edge, so the sweep below can find them +-- without any page needing to know what it contains. Weak keys: a retired page's +-- widgets are reparented to the trash frame rather than destroyed, and this must +-- not be what keeps them reachable. + +-- Re-derive the BORDER THICKNESS of everything currently on screen. Since the +-- geometry correction was removed this no longer moves or resizes anything, so +-- the only thing it can change is how many device pixels an edge is drawn at -- +-- which only matters when the SCALE changes. That is why its callers are the +-- scale slider and the window drag/resize handlers, not anything per-frame. +-- +-- IsVisible (not IsShown) keeps it cheap: a widget on a page that is not open +-- has a hidden ancestor and is skipped, so the work stays proportional to what +-- is displayed rather than to everything the registry has accumulated. Safe to +-- call repeatedly -- it writes only when the computed edge width actually +-- differs, so a second call in the same state does nothing at all. + +-- ============================================================ +-- PIXEL BORDER -- how every outlined GUI surface draws its edge. +-- +-- Four textures we own, instead of a backdrop's edgeFile, because a backdrop +-- edge is drawn one UI unit wide and we cannot control how that lands on the +-- physical pixel grid. At the scales people play at, one unit is about one +-- device pixel, and a one-device-pixel line has exactly two states: [1.0, 0] +-- when it lands on the grid and [0.5, 0.5] when it does not. Identical ink, +-- completely different appearance -- so an edge that crosses the grid while the +-- page scrolls appears to flicker, and at the low alphas this GUI uses, both +-- halves of the split can fall under the visibility floor and the border simply +-- is not there. +-- +-- The long way round to this was trying to place that 1px line perfectly. It +-- cannot be done. Lua-side snapping runs at discrete moments -- build, +-- scroll-settle, show -- but the line crosses the grid on EVERY frame of a +-- scroll: correcting at moment N does nothing for the next thirty frames, and +-- correcting mid-motion adds a visible jump on top of the softness. Handing it +-- to the renderer (SetSnapToPixelGrid) is worse still: it rounds the two edges +-- of a thin texture independently, so a 1px line can round to zero height and +-- vanish outright. +-- +-- The answer is to stop placing a thin line accurately and draw one that does +-- not care where it lands. See PX_BORDER_THICKNESS below. +local pixelBordered = setmetatable({}, { __mode = "k" }) +local PX_SIDES = { "top", "bottom", "left", "right" } + +-- THE dial. Border thickness in DEVICE PIXELS, before any per-surface weight. +-- +-- The floor is what matters, not the exact value: anything above 1 always covers +-- at least one FULL pixel row plus a partial, so the line can never vanish and +-- never changes its total ink as the content moves. Only 1.0 has the two-state +-- failure ([1.0, 0] when it lands on the grid, [0.5, 0.5] when it does not -- +-- identical ink, completely different appearance), which is the flicker that +-- started this. +-- +-- Back to 2, and the alpha carries the weight instead. THICKNESS and WEIGHT are +-- separate dials and conflating them was the mistake: +-- +-- * thickness decides how UNIFORM the line looks. At 1.5 the rows come out +-- [0.25, 1.0, 0.25] or [0.5, 1.0] depending on where the box lands, so +-- neighbouring boxes render visibly different widths -- Krathe's "one +-- thinner, one thicker". The bigger the base, the smaller that proportion, +-- so 2 is noticeably more even than 1.5. +-- * alpha decides how HEAVY it looks, and does not vary with position at all. +-- +-- So: hold thickness at the value that renders evenly, and take the weight out +-- of the alpha. 2px at 0.7 alpha is about the same total ink as 1.5px at full, +-- but without the width wobble. +-- +-- The floor still matters if this is ever tuned down: anything above 1 always +-- covers a full row plus a partial, so it cannot vanish. Only 1.0 has the +-- two-state failure -- [1.0, 0] on the grid, [0.5, 0.5] off it, identical ink +-- and completely different appearance -- which was the original flicker. +local PX_BORDER_THICKNESS = 2 + +-- Applied to every pixel border's alpha. The authored colours were all chosen +-- for a 1px hairline; drawing them at 2px would read heavier than intended, and +-- this puts that correction in ONE place rather than re-tuning six call sites +-- (and whatever opts in later) by hand. +local PX_BORDER_ALPHA = 0.7 + +-- Thickness and anchors, re-derived whenever the scale changes. +-- frame._pxWeight is the caller's edgeSize (1 = the standard hairline), so a +-- surface that asked for a heavier outline keeps its relative weight. +local function LayoutPixelBorder(frame) + local b = frame._pxBorder + if not b then return end + local ppu = PixelsPerUnit(frame) + if not ppu then return end + -- TWO device pixels, not one, and this is the whole finding. + -- + -- A 1px line cannot be drawn reliably at a fractional offset. With vertex + -- snapping ON, the top and bottom of a 1px-tall texture can round to the SAME + -- pixel row -- height zero, line GONE. That is what the red diagnostic showed: + -- verticals solid (X is stable), horizontals absent at certain scroll + -- positions (Y carries the scroll's fractional phase). The old backdrop edge + -- did it too, so this is not specific to either mechanism. + -- + -- With snapping OFF at 2px the rows come out [partial, full, partial] -- + -- roughly 0.5 / 1.0 / 0.5 -- for ANY offset. The total ink is constant and + -- there is always at least one fully-lit row, so the line can neither vanish + -- nor visibly change weight as the page scrolls. A 1px line has only the two + -- states [1.0, 0] and [0.5, 0.5]: same ink, completely different appearance, + -- which IS the flicker. + -- + -- So: stop trying to place a 1px line perfectly, and draw one that does not + -- care where it lands. + local devicePx = PX_BORDER_THICKNESS * (frame._pxWeight or 1) + local px = devicePx / ppu + frame._pxDevicePx = devicePx -- what /df debug pixelcheck reports for this surface + frame._pxPpu = ppu -- the scale this thickness was derived at + + b.top:ClearAllPoints() + b.top:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + b.top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + b.top:SetHeight(px) + + b.bottom:ClearAllPoints() + b.bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + b.bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + b.bottom:SetHeight(px) + + b.left:ClearAllPoints() + b.left:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + b.left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + b.left:SetWidth(px) + + b.right:ClearAllPoints() + b.right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + b.right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + b.right:SetWidth(px) +end + +-- THE SHIM, and the reason buttons could not adopt this until now. +-- +-- A pixel border has no backdrop edgeFile, so SetBackdropBorderColor -- which is +-- how ~150 call sites across the addon drive hover, active and disabled states +-- -- would silently do nothing. Converting those by hand meant finding all of +-- them, and missing one meant a button whose hover just stops working, with +-- nothing to see in a log. +-- +-- So the frame gets its own method that shadows the mixin's. Every existing +-- caller keeps working unchanged, and there is no list to be exhaustive about. +-- Assigning onto the frame table is enough: SetBackdropBorderColor is not a +-- native widget method, it arrives via BackdropTemplateMixin, so it is already +-- a plain table entry and ours simply takes its place. +local function SetPixelBorderColor(self, r, g, b, a) + local bd = self._pxBorder + if not bd then return end + a = a or 1 + -- Mutated, not replaced. This is the hover path for every button, dropdown + -- and swatch in the GUI -- OnEnter and OnLeave both land here -- so a fresh + -- table per call would be pure garbage. + local c = self._pxColor + if c then c[1], c[2], c[3], c[4] = r, g, b, a + else self._pxColor = { r, g, b, a } end + -- Alpha scaled once, here, so the authored colours stay the values a reader + -- would expect to see (8%, 50%, 80%) rather than pre-compensated numbers. + local drawn = a * PX_BORDER_ALPHA + for _, side in ipairs(PX_SIDES) do + bd[side]:SetColorTexture(r, g, b, drawn) + end +end + +-- Returns the AUTHORED colour, not the drawn one. PX_BORDER_ALPHA is a +-- rendering correction, so a caller that reads a colour back and writes it again +-- must not end up with it applied twice. +local function GetPixelBorderColor(self) + local c = self._pxColor + if not c then return end + return c[1], c[2], c[3], c[4] +end + +-- Re-derive on show, so a surface built at one UI scale and opened after a scale +-- change comes up at the right thickness. Self-registering on purpose: this +-- used to be an explicit call that six floating windows each had to remember to +-- make, and a bordered frame maintaining its own border cannot be forgotten. +-- +-- Guarded on the scale it was last laid out at, because this fires for every +-- widget on every page open. In the ordinary case -- nothing has changed since +-- the widget was built -- it costs one float compare and returns. +local function RelayoutPixelBorderOnShow(self) + if PixelsPerUnit(self) ~= self._pxPpu then LayoutPixelBorder(self) end +end + +-- color = {r, g, b, a}; weight mirrors backdrop edgeSize (1 = standard hairline) +function GUI:ApplyPixelBorder(frame, color, weight) + frame._pxWeight = weight or frame._pxWeight or 1 + local b = frame._pxBorder + if not b then + b = {} + for _, side in ipairs(PX_SIDES) do + -- ARTWORK at the top sublevel, not BORDER. The colour picker is + -- what forces it up: its hue square lays a full-size ARTWORK + -- gradient over the frame, which would cover a BORDER-layer edge + -- completely -- and that is a trap, because the border would + -- disappear for a reason nothing about the border explains. + -- + -- Not OVERLAY, though. Every label, icon, check mark, grip and + -- slider thumb in this GUI is OVERLAY, and those belong above the + -- edge they sit inside. Sublevel 7 of ARTWORK clears the interior + -- fills and gradients and nothing else. + local tex = frame:CreateTexture(nil, "ARTWORK", nil, 7) + -- Snapping OFF, deliberately, and this is a reversal of what this + -- started out as. Vertex snapping is what COLLAPSES a thin line: + -- rounding a 2px texture's edges independently gives a height of 1, + -- 2 or 3 px depending on where it lands, so the border would + -- visibly change weight as you scroll. Left un-snapped it is always + -- exactly 2px of ink, wherever it falls -- constant, which is what + -- the eye actually wants. Crispness was never the goal; STABILITY + -- was. + if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end + -- No half-texel offset, so the 2px spans the rows we asked for. + if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end + b[side] = tex + end + frame._pxBorder = b + pixelBordered[frame] = true + if frame.HookScript then + frame:HookScript("OnShow", RelayoutPixelBorderOnShow) + end + end + -- Outside the create block on purpose: the textures are kept when a surface + -- is re-issued, so keying the shim off their creation would leave a + -- re-adopted frame with the real methods still in place and its recolours + -- going nowhere. Assigning every time is idempotent and cannot get this + -- wrong. + frame.SetPixelBorderColor = SetPixelBorderColor + frame.SetBackdropBorderColor = SetPixelBorderColor + frame.GetBackdropBorderColor = GetPixelBorderColor + local c = color or { 1, 1, 1, 0.08 } + SetPixelBorderColor(frame, c[1], c[2], c[3], c[4] or 1) + -- Re-shown because a surface can be re-issued as fill-only and back again + -- (FlashWidget does this on every pulse). + for _, side in ipairs(PX_SIDES) do b[side]:Show() end + LayoutPixelBorder(frame) + return frame +end + +-- Hand the frame its own methods back, and take the textures down. Needed by the +-- one path that leaves the pixel border behind: a surface re-issued with +-- opts.backdropEdge after it had one. Nothing does that today, but if the shim +-- were left installed the backdrop recolour that follows would go nowhere at +-- all -- a hover that silently stops working, which is precisely the failure +-- this system exists to make impossible. +local function RevertPixelBorder(frame) + if not frame or not frame._pxBorder then return end + GUI:HidePixelBorder(frame) + frame.SetPixelBorderColor = nil + frame.SetBackdropBorderColor = BackdropTemplateMixin.SetBackdropBorderColor + frame.GetBackdropBorderColor = BackdropTemplateMixin.GetBackdropBorderColor +end + +-- Take the border down without discarding it. The textures are ours, so unlike a +-- backdrop edge nothing else will remove them when the surface is re-issued +-- without an outline. +function GUI:HidePixelBorder(frame) + local b = frame and frame._pxBorder + if not b then return end + for _, side in ipairs(PX_SIDES) do b[side]:Hide() end +end + +-- Re-derive thickness on a scale change. Rides the same sweep that already +-- re-derives backdrop edge widths, so there is no new per-frame work. +function GUI:RefreshPixelBorders() + for frame in pairs(pixelBordered) do + if frame:IsVisible() then LayoutPixelBorder(frame) end + end +end + +-- THE element backdrop. Every bordered/filled GUI surface goes through here, so +-- a change to the look lands everywhere at once. Three shapes, one code path: +-- +-- (default) fill + 1px outline -- dropdowns, edit boxes, buttons +-- opts.fill=false outline only -- for a surface whose interior is drawn by +-- something else (the colour picker's hue/alpha gradients +-- and checkerboards), where a fill would paint over it +-- opts.outline=false fill only -- flat chips, segment buttons, label plates +-- +-- opts.bgColor / opts.borderColor take {r,g,b[,a]} or {[1],[2],[3][,4]} and +-- override the C_ELEMENT / C_BORDER defaults. opts.edgeSize thickens the outline +-- (the popup and wizard chrome use 2). opts.inset (a +-- single number, applied to all four sides) pulls the fill in from the edge so it +-- does not underlap a translucent border -- default 0, i.e. the fill runs to the +-- frame edge. opts.backdropEdge draws the outline the old way, as a backdrop +-- edgeFile; see below for why nothing should want that. +local function CreateElementBackdrop(frame, opts) + opts = opts or {} + local fill, outline = opts.fill ~= false, opts.outline ~= false + -- The outline is drawn as our own textures, not a backdrop edgeFile. See + -- ApplyPixelBorder -- a 1px backdrop edge cannot be drawn reliably at a + -- fractional offset, which is the whole missing-border story. + -- + -- This was an opt-in while the hover states were still a problem: a pixel + -- border is not repainted by SetBackdropBorderColor, so converting buttons + -- blind would have silently killed every hover in the GUI. The shim in + -- ApplyPixelBorder settles that -- the frame gets its own + -- SetBackdropBorderColor -- so it is now the default for everything that + -- comes through here, and opts.backdropEdge is the escape hatch for a + -- surface that genuinely needs the old edgeFile. + local usePixel = outline and not opts.backdropEdge + if not frame.SetBackdrop then Mixin(frame, BackdropTemplateMixin) end + local inset = opts.inset + frame:SetBackdrop({ + bgFile = fill and "Interface\\Buttons\\WHITE8x8" or nil, + edgeFile = (outline and not usePixel) and "Interface\\Buttons\\WHITE8x8" or nil, + edgeSize = (outline and not usePixel) and (opts.edgeSize or 1) or nil, + insets = inset and { left = inset, right = inset, + top = inset, bottom = inset } or nil, + }) + if fill then + local c = opts.bgColor + if c then frame:SetBackdropColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) + else frame:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, C_ELEMENT.a) end + end + if outline then + local c = opts.borderColor + if usePixel then + GUI:ApplyPixelBorder(frame, + c and { c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1 } + or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5 }, + opts.edgeSize) + else + RevertPixelBorder(frame) + if c then + frame:SetBackdropBorderColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) + else + frame:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + end + end + else + -- A backdrop edge disappears on its own when the backdrop is re-issued + -- without one; our textures do not, so an outlined surface re-issued as + -- fill-only has to be told. FlashWidget does exactly this: it re-runs + -- this factory on every pulse, with outline true or false depending on + -- how the caller wants that pulse to read. + GUI:HidePixelBorder(frame) + end + return frame +end + +-- The window chrome: the settings window itself, the changelog overlay and the +-- dropdown menu panel. Distinct from the public GUI:CreatePanelBackdrop further +-- down (dialogs and floating panels, colour-configurable) -- this one is always +-- the dark background behind a hard black edge. +-- +-- Routed through the factory rather than issuing its own backdrop, which is what +-- it used to do. It was the last thing in the GUI still drawing a border as an +-- edgeFile, and a black 1px line is the case where that reads least badly -- it +-- has enough contrast that a split across two device rows softens it instead of +-- losing it. Still worth converting: softening on every scroll is what the whole +-- pixel border exists to stop, and while this was the last holdout the entire +-- snap-registry existed to serve three call sites. +local function CreatePanelBackdrop(frame) + return CreateElementBackdrop(frame, { + bgColor = C_BACKGROUND, + borderColor = { 0, 0, 0, 1 }, + }) +end + +-- Style a ScrollFrameTemplate scrollbar to use the pill-shaped thumb +-- All scroll frames must use ScrollFrameTemplate (not UIPanelScrollFrameTemplate) +-- +-- This used to also quantise the scroll offset to whole device pixels, on the +-- reasoning that Blizzard's scrollbar drives the offset as a fraction of the +-- range and so parks the content on an arbitrary sub-pixel row. That was true, +-- and it did not help: a thin border is soft at SOME offsets no matter which +-- offsets you allow, and quantising only changed which ones. The border being +-- two device pixels wide is what made the question stop mattering -- it draws +-- the same amount of ink wherever it lands. Do not add it back. +local function StyleScrollBar(scrollFrame) + local sb = scrollFrame.ScrollBar + if not sb then return end + + -- Hide track background and track end caps + if sb.Background then sb.Background:Hide() end + if sb.Track then + if sb.Track.Begin then sb.Track.Begin:Hide() end + if sb.Track.End then sb.Track.End:Hide() end + if sb.Track.Middle then sb.Track.Middle:Hide() end + end + + -- Style the pill-shaped thumb — hide default textures, overlay with themed color + if sb.Thumb then + if sb.Thumb.Begin then sb.Thumb.Begin:Hide() end + if sb.Thumb.End then sb.Thumb.End:Hide() end + if sb.Thumb.Middle then sb.Thumb.Middle:Hide() end + if not sb.Thumb.customBg then + local thumb = sb.Thumb:CreateTexture(nil, "ARTWORK") + thumb:SetAllPoints() + thumb:SetColorTexture(0.4, 0.4, 0.4, 0.8) + sb.Thumb.customBg = thumb + end + end + + -- Hide navigation buttons + if sb.Back then sb.Back:Hide() sb.Back:SetSize(1, 1) end + if sb.Forward then sb.Forward:Hide() sb.Forward:SetSize(1, 1) end + + -- Slim width + sb:SetWidth(10) +end +GUI.StyleScrollBar = StyleScrollBar + +-- ========================================================================= +-- WIDGET FACTORY +-- ========================================================================= + +function GUI:CreateHeader(parent, text) + -- Use a frame container so we can position text at bottom (padding above) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(200, 25) + container:Show() + container.rowKind = "header" + -- Factory-owned slot, and fixed so ResolveRowHeight ignores whatever the call + -- site passes. Headers were handed 25 in collapsible groups and 40 in plain + -- ones -- the same widget, two rhythms, across ~200 sites. Owning it here + -- unifies them without touching any of those call sites, which is exactly the + -- rule the other factory rows already follow (see GUI.RowHeight). + container.preferredHeight = GUI.RowHeight.sectionHeader + container.fixedRowHeight = true + + local h = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") + h:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 0, 2) + h:SetText(text) + local c = GetThemeColor() + h:SetTextColor(c.r, c.g, c.b) + h:SetJustifyH("LEFT") + h.UpdateTheme = function() local nc = GetThemeColor() h:SetTextColor(nc.r, nc.g, nc.b) end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, h) + + -- Store text reference + container.text = h + + -- Forward IsShown to ensure layout works + container.GetText = function() return h:GetText() end + + -- SEARCH: Track current section + if DF.Search then + DF.Search:SetCurrentSection(text) + end + + return container +end + +-- Collapsible section for grouping related settings. +-- Collapsed state is persisted in DandersFramesDB_v2.collapsedGroups keyed by +-- `text` (shared store with CreateSettingsGroup's collapsible header), so the +-- user's fold preference survives reloads. +function GUI:CreateCollapsibleSection(parent, text, defaultExpanded, width) + local section = CreateFrame("Frame", nil, parent, "BackdropTemplate") + section:SetSize(width or 500, 28) -- Header height + -- Resolve initial expanded state: SavedVariables override the default. + local savedStates = GUI:GetCollapsedGroups() + if text and savedStates[text] ~= nil then + section.expanded = not savedStates[text] + else + section.expanded = defaultExpanded ~= false + end + section.sectionTitleText = text + section.sectionChildren = {} + -- Same contract as CreateHeader's container.GetText: it is how a section is FOUND + -- by title (Search:ScrollToSection, and every GUI:LinkToSetting{ section = ... } + -- cross-link through it). Without it a collapsible section is invisible to those + -- lookups, so a link to it silently does nothing — which is exactly what happened + -- to the Colours page's "Color by Time" links when that box was promoted from a + -- plain header to a collapsible section. + section.GetText = function(self) return self.sectionTitleText end + section.paddingAfter = 8 -- Padding space after header before first child + + -- Header bar with background. Same look as before, via the shared backdrop + -- helper rather than a private copy of it, so it picks up the pixel border + -- (and anything else that lands there) without its own wiring. + GUI:CreateElementBackdrop(section, { + bgColor = { r = C_PANEL.r, g = C_PANEL.g, b = C_PANEL.b, a = 0.8 }, + borderColor = { r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5 }, + }) + + -- Click area + local clickArea = CreateFrame("Button", nil, section) + clickArea:SetAllPoints() + clickArea:EnableMouse(true) + + -- Expand/collapse arrow icon + section.arrow = section:CreateTexture(nil, "OVERLAY") + section.arrow:SetPoint("LEFT", 8, 0) + section.arrow:SetSize(12, 12) + if section.expanded then + section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + else + section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") + end + section.arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Section title + section.title = section:CreateFontString(nil, "OVERLAY", "DFFontNormal") + section.title:SetPoint("LEFT", 26, 0) + section.title:SetText(text) + local c = GetThemeColor() + section.title:SetTextColor(c.r, c.g, c.b) + section.title.UpdateTheme = function() + if section.previewDimmed then + section.title:SetTextColor(0.5, 0.5, 0.5) + else + local nc = GetThemeColor() + section.title:SetTextColor(nc.r, nc.g, nc.b) + end + end + -- Grey the header title when the section's feature is disabled (driven by + -- the preview wiring). Routes through UpdateTheme so theme changes respect it. + section.SetPreviewDimmed = function(self, dimmed) + self.previewDimmed = dimmed and true or false + self.title.UpdateTheme() + end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, section.title) + + -- Optional inline tag — small yellow text placed after the title to + -- stand out as a status summary (e.g. "[Normal Dispels]"). Call + -- section:SetTag(text) at any time; pass nil or empty string to clear. + section.tag = section:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + section.tag:SetPoint("LEFT", section.title, "RIGHT", 8, 0) + section.tag:SetTextColor(1, 0.82, 0, 1) -- WoW standard gold/yellow + section.tag:SetText("") + section.SetTag = function(self, text) + if text and text ~= "" then + self.tag:SetText(text) + self.tag:Show() + else + self.tag:SetText("") + self.tag:Hide() + end + end + + -- SEARCH: Track current section + if DF.Search then + DF.Search:SetCurrentSection(text) + end + + -- Toggle function + section.Toggle = function(self) + self.expanded = not self.expanded + if self.expanded then + self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + else + self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") + end + -- Persist collapsed state to SavedVariables (only store true, remove when expanded) + if self.sectionTitleText then + local saved = GUI:GetCollapsedGroups() + saved[self.sectionTitleText] = (not self.expanded) or nil + end + + -- Trigger layout refresh (RefreshStates handles show/hide based on expanded state) + if parent.RefreshStates then + parent:RefreshStates() + end + end + + -- Register child widgets to this section + section.RegisterChild = function(self, widget) + table.insert(self.sectionChildren, widget) + widget.parentSection = self + + -- Use a marker to check section state during RefreshStates + widget.collapsibleSection = self + end + + -- Optional header preview thumbnails — a right-aligned row of small icon + -- swatches on the header bar, used to show the actual icon(s) a section + -- controls (e.g. the Role Icon section previews the Tank/Healer/DPS icons in + -- the currently selected style). Always visible on the header, so the page + -- reads as a gallery whether sections are expanded or collapsed. + -- + -- icons: array of entries, each EITHER an icon or a text label: + -- { texture = "atlas-or-path", coords = {l,r,t,b}?, desaturate = bool? } + -- { text = "MT", desaturate = bool? } + -- Icon entries are fixed-width swatches; text entries are sized to the + -- string. Entries flow right-to-left from the header's right edge so the + -- first entry sits leftmost. nil/empty clears the preview. + section.previewIcons = {} + section.SetPreviewIcons = function(self, icons) + local pool = self.previewIcons + local n = icons and #icons or 0 + local SIZE, GAP, RIGHT_INSET = 18, 4, -10 + local x = RIGHT_INSET + for i = n, 1, -1 do -- right-to-left so entry 1 ends up leftmost + local data = icons[i] + local slot = pool[i] + if not slot then + slot = CreateFrame("Frame", nil, self) + slot:SetHeight(SIZE) + slot.tex = slot:CreateTexture(nil, "OVERLAY") + slot.tex:SetAllPoints() + slot.fs = slot:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + slot.fs:SetAllPoints() + slot.fs:SetJustifyH("CENTER") + pool[i] = slot + end + local dim = data.desaturate and true or false + local w = SIZE + if data.text and data.text ~= "" then + slot.tex:Hide() + slot.fs:SetText(data.text) + if dim then + slot.fs:SetTextColor(0.5, 0.5, 0.5, 1) + elseif data.color then + slot.fs:SetTextColor(data.color.r or 1, data.color.g or 1, data.color.b or 1, data.color.a or 1) + else + slot.fs:SetTextColor(1, 0.82, 0, 1) + end + slot.fs:Show() + w = math.max(SIZE, (slot.fs:GetStringWidth() or 0) + 4) + else + slot.fs:Hide() + -- data.texture may be an atlas name or a texture path; the helper + -- prefers the atlas and falls back to the path (+ optional coords). + local co = data.coords + DF:SetIconTextureOrAtlas(slot.tex, data.texture, co and co[1], co and co[2], co and co[3], co and co[4]) + slot.tex:SetDesaturated(dim) + -- Optional per-entry inset: textures that fill their cell edge-to-edge + -- (e.g. raid-target markers) read bigger than the padded status-icon + -- atlases. data.inset shrinks the swatch to match. + local pad = data.inset or 0 + slot.tex:ClearAllPoints() + slot.tex:SetPoint("TOPLEFT", slot, "TOPLEFT", pad, -pad) + slot.tex:SetPoint("BOTTOMRIGHT", slot, "BOTTOMRIGHT", -pad, pad) + slot.tex:Show() + end + slot:SetWidth(w) + slot:ClearAllPoints() + slot:SetPoint("RIGHT", self, "RIGHT", x, 0) + slot:Show() + x = x - w - GAP + end + for i = n + 1, #pool do pool[i]:Hide() end + end + + -- Hover effects + clickArea:SetScript("OnEnter", function() + section:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.8) + end) + clickArea:SetScript("OnLeave", function() + section:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.8) + end) + clickArea:SetScript("OnClick", function() + section:Toggle() + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + + return section +end + +-- ========================================================================= +-- SETTINGS GROUP - Visible container that groups related settings together +-- Ensures settings never get split across columns +-- ========================================================================= +-- Collapsed state persistence (stored in SavedVariables, survives logout) +-- Lazily initialized from DandersFramesDB_v2.collapsedGroups on first access +function GUI:GetCollapsedGroups() + if not DandersFramesDB_v2 then return {} end + if not DandersFramesDB_v2.collapsedGroups then + DandersFramesDB_v2.collapsedGroups = {} + end + return DandersFramesDB_v2.collapsedGroups +end \ No newline at end of file diff --git a/GUI/Panel.lua b/GUI/Panel.lua new file mode 100644 index 00000000..2dc31d20 --- /dev/null +++ b/GUI/Panel.lua @@ -0,0 +1,1965 @@ +-- Part 5 of the GUI toolkit, split from the original GUI.lua. +-- These re-declarations are aliases of the SAME objects the first part +-- created; they add no state. See docs/reorg-tools/splits.manifest. +local addonName, DF = ... +local GUI = DF.GUI +local L = DF.L +local S = GUI._state +local C_PANEL, C_ELEMENT, C_BORDER, C_ACCENT, C_RAID, C_HOVER, C_TEXT, C_TEXT_DIM = + GUI.Colors.panel, GUI.Colors.element, GUI.Colors.border, GUI.Colors.accent, GUI.Colors.raid, GUI.Colors.hover, GUI.Colors.text, GUI.Colors.textDim +local ResolveRowHeight = GUI.ResolveRowHeight +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local SnapLenUp = GUI.SnapLenUp +local CreateElementBackdrop = GUI.CreateElementBackdrop +local CreatePanelBackdrop = GUI.CreatePanelBackdrop +local StyleScrollBar = GUI.StyleScrollBar +local RefreshAllOverrideIndicators = GUI.RefreshAllOverrideIndicators +function DF:CreateGUI() + if DF.GUIFrame then return end + + -- Default and saved sizes + local defaultWidth, defaultHeight = 760, 520 + local minWidth, minHeight = 520, 400 + local maxWidth, maxHeight = 1200, 900 + + -- Load saved position and size (stored in party db since it's always available) + local guiDb = DF.db and DF.db.party or {} + local savedScale = guiDb.guiScale or 1.0 + local savedWidth = guiDb.guiWidth or defaultWidth + local savedHeight = guiDb.guiHeight or defaultHeight + + -- Main frame (matching old addon approach - no BackdropTemplate in CreateFrame) + local frame = CreateFrame("Frame", "DandersFramesGUI", UIParent) + frame:SetSize(savedWidth, savedHeight) + -- Restore saved position, or default to center + if guiDb.guiPoint and guiDb.guiX then + frame:SetPoint(guiDb.guiPoint, UIParent, guiDb.guiRelPoint or "CENTER", guiDb.guiX, guiDb.guiY) + else + frame:SetPoint("CENTER") + end + frame:SetFrameStrata("DIALOG") -- Match old addon + frame:SetToplevel(true) -- Match old addon + frame:SetMovable(true) + frame:SetResizable(true) + frame:SetResizeBounds(minWidth, minHeight, maxWidth, maxHeight) + frame:EnableMouse(true) + frame:SetScale(savedScale) + -- Note: Dragging is handled by titleBar, not main frame + CreatePanelBackdrop(frame) + frame:Hide() + DF.GUIFrame = frame + + -- Allow closing with Escape key + tinsert(UISpecialFrames, "DandersFramesGUI") + + -- Exit profile editing when GUI is closed + frame:SetScript("OnHide", function() + local AutoProfilesUI = DF.AutoProfilesUI + if AutoProfilesUI and AutoProfilesUI:IsEditing() then + AutoProfilesUI:ExitEditing(true) -- Skip UI updates since GUI is closing + end + end) + + -- Title bar (handles dragging like old addon) + -- Uses FULLSCREEN_DIALOG strata so it stays above dropdown menus and popups, + -- allowing the window to be dragged even when settings panels are open. + local titleBar = CreateFrame("Frame", nil, frame) + titleBar:SetHeight(30) + titleBar:SetPoint("TOPLEFT", 0, 0) + titleBar:SetPoint("TOPRIGHT", -30, 0) + titleBar:EnableMouse(true) + titleBar:RegisterForDrag("LeftButton") + titleBar:SetScript("OnDragStart", function() frame:StartMoving() end) + titleBar:SetScript("OnDragStop", function() + frame:StopMovingOrSizing() + -- Save position so it persists across sessions + local point, _, relPoint, x, y = frame:GetPoint() + if DF.db and DF.db.party then + DF.db.party.guiPoint = point + DF.db.party.guiRelPoint = relPoint + DF.db.party.guiX = x + DF.db.party.guiY = y + end + end) + titleBar:SetFrameStrata("FULLSCREEN_DIALOG") + titleBar:SetFrameLevel(200) + + local title = titleBar:CreateFontString(nil, "OVERLAY", "DFFontNormal") + title:SetPoint("LEFT", 12, 0) + local versionStr = DF.VERSION or "Unknown" + local channelTags = { alpha = " |cffff8800alpha|r", beta = " |cffff8800beta|r" } + local channelTag = channelTags[DF.RELEASE_CHANNEL] or "" + title:SetText("DandersFrames " .. versionStr .. channelTag) + local c = GetThemeColor() + title:SetTextColor(c.r, c.g, c.b) + title.UpdateTheme = function() + local nc = GetThemeColor() + title:SetTextColor(nc.r, nc.g, nc.b) + end + + -- Close button with icon + local closeBtn = GUI:CreateCloseButton(frame, { size = 20, onClick = function() frame:Hide() end }) + closeBtn:SetPoint("TOPRIGHT", -8, -5) + closeBtn:SetFrameStrata("FULLSCREEN_DIALOG") + closeBtn:SetFrameLevel(210) + + -- Info button (changelog) + local infoBtn = CreateFrame("Button", nil, frame, "BackdropTemplate") + infoBtn:SetPoint("TOPRIGHT", -32, -5) + infoBtn:SetFrameStrata("FULLSCREEN_DIALOG") + infoBtn:SetFrameLevel(210) + -- Icon-only changelog button via the shared styler (backdrop + hover); the hook + -- brightens the icon to the theme colour on hover. + GUI:StyleButton(infoBtn, { + width = 20, height = 20, + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\notes", size = 16, color = C_TEXT_DIM }, + }) + infoBtn:HookScript("OnEnter", function(self) + local tc = GetThemeColor() + self.Icon:SetVertexColor(tc.r, tc.g, tc.b) + end) + infoBtn:HookScript("OnLeave", function(self) + self.Icon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end) + + -- Changelog overlay (covers full content area below title bar) + local changelogOverlay = CreateFrame("Frame", nil, frame, "BackdropTemplate") + changelogOverlay:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, -30) + changelogOverlay:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + changelogOverlay:SetFrameStrata("FULLSCREEN_DIALOG") + changelogOverlay:SetFrameLevel(300) + CreatePanelBackdrop(changelogOverlay) + changelogOverlay:Hide() + GUI.changelogOverlay = changelogOverlay + + -- Header bar within the overlay + local changelogHeader = CreateFrame("Frame", nil, changelogOverlay) + changelogHeader:SetPoint("TOPLEFT", 8, -8) + changelogHeader:SetPoint("TOPRIGHT", -8, -8) + changelogHeader:SetHeight(24) + + local changelogTitle = changelogHeader:CreateFontString(nil, "OVERLAY", "DFFontNormal") + changelogTitle:SetPoint("LEFT", 4, 0) + changelogTitle:SetText(L["Changelog"] .. " — " .. versionStr) + changelogTitle:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local backBtn = CreateFrame("Button", nil, changelogHeader, "BackdropTemplate") + backBtn:SetPoint("RIGHT", 0, 0) + GUI:StyleButton(backBtn, { width = 60, height = 22, text = L["Close"] }) + backBtn:SetScript("OnClick", function() changelogOverlay:Hide() end) + + -- Convert markdown changelog to WoW color-coded plain text + local function FormatChangelog(text) + if not text or text == "" then return L["No changelog available."] end + local tc = GetThemeColor() + local themeHex = format("%02x%02x%02x", tc.r * 255, tc.g * 255, tc.b * 255) + local dimHex = format("%02x%02x%02x", C_TEXT_DIM.r * 255, C_TEXT_DIM.g * 255, C_TEXT_DIM.b * 255) + local textHex = format("%02x%02x%02x", C_TEXT.r * 255, C_TEXT.g * 255, C_TEXT.b * 255) + + local lines = {} + for line in text:gmatch("[^\n]*") do + if line:match("^# ") then + -- Main title — skip (already shown in header bar) + elseif line:match("^## ") then + -- Version header + local content = line:gsub("^##%s*", "") + lines[#lines + 1] = format("|cff%s%s|r", themeHex, content) + elseif line:match("^### ") then + -- Section header + local content = line:gsub("^###%s*", "") + lines[#lines + 1] = format("\n|cff%s%s|r", textHex, content) + elseif line:match("^%*%s") or line:match("^%-%s") then + -- Bullet point + local content = line:gsub("^[%*%-]%s*", "") + lines[#lines + 1] = format(" |cff%s\226\128\162|r |cff%s%s|r", themeHex, dimHex, content) + elseif line:match("^%s*$") then + lines[#lines + 1] = "" + else + lines[#lines + 1] = format("|cff%s%s|r", dimHex, line) + end + end + + return table.concat(lines, "\n") + end + + -- plain: the overlay already IS the panel, so the text area contributes only + -- the scrolling field. readOnly rather than EnableKeyboard(false) — the + -- changelog stays uneditable but becomes selectable and copyable. + local changelogArea = GUI:CreateTextArea(changelogOverlay, { + plain = true, + readOnly = true, + text = FormatChangelog(DF.CHANGELOG_TEXT), + }) + changelogArea:SetPoint("TOPLEFT", 8, -38) + changelogArea:SetPoint("BOTTOMRIGHT", -8, 8) + GUI.FormatChangelog = FormatChangelog + GUI.changelogArea = changelogArea -- .EditBox for the field itself + + infoBtn:SetScript("OnClick", function() + if changelogOverlay:IsShown() then + changelogOverlay:Hide() + else + -- No width fix-up needed: the text area re-syncs its own scroll child. + changelogArea:SetText(FormatChangelog(DF.CHANGELOG_TEXT)) + changelogOverlay:Show() + end + end) + + -- ========================================================================= + -- RESIZE HANDLE (bottom-right corner) + -- ========================================================================= + local resizeHandle = CreateFrame("Button", nil, frame) + resizeHandle:SetSize(16, 16) + resizeHandle:SetPoint("BOTTOMRIGHT", -2, 2) + resizeHandle:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") + resizeHandle:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight") + resizeHandle:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down") + resizeHandle:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + frame:StartSizing("BOTTOMRIGHT") + end + end) + resizeHandle:SetScript("OnMouseUp", function(self, button) + frame:StopMovingOrSizing() + -- Save new size + DF.db.party.guiWidth = frame:GetWidth() + DF.db.party.guiHeight = frame:GetHeight() + -- Update content layout + if GUI.SelectedMode == "clicks" then + -- Refresh click casting UI on resize (skip scroll reset) + if DF.ClickCast and DF.ClickCast.RefreshSpellGrid then + DF.ClickCast:RefreshSpellGrid(true) + end + elseif GUI.RefreshCurrentPage then + GUI:RefreshCurrentPage() + end + end) + + -- Party/Raid mode toggle buttons + local btnParty = CreateFrame("Button", nil, frame, "BackdropTemplate") + -- Head of the toolbar chain (Party <- Raid <- Clicks <- Test <- Unlock), so + -- this offset is what every button along it inherits. + btnParty:SetPoint("TOPLEFT", SnapLen(frame, 12), SnapLen(frame, -32)) + -- Shared underline-tab style; SetActive (in UpdateThemeColors) drives it. + GUI:StyleButton(btnParty, { tab = true, text = L["PARTY"], accent = C_ACCENT, width = 70, height = 24, font = "DFFontHighlight" }) + GUI.PartyButton = btnParty -- Store for external access + + local btnRaid = CreateFrame("Button", nil, frame, "BackdropTemplate") + btnRaid:SetPoint("LEFT", btnParty, "RIGHT", SnapLen(btnRaid, 4), 0) + GUI:StyleButton(btnRaid, { tab = true, text = L["RAID"], accent = C_RAID, width = 70, height = 24, font = "DFFontHighlight" }) + GUI.RaidButton = btnRaid -- Store for external access + + -- Click Casting tab button + local btnClicks = CreateFrame("Button", nil, frame, "BackdropTemplate") + btnClicks:SetPoint("LEFT", btnRaid, "RIGHT", SnapLen(btnClicks, 4), 0) + GUI:StyleButton(btnClicks, { tab = true, text = L["BINDS"], accent = { r = 0.2, g = 0.8, b = 0.4 }, width = 70, height = 24, font = "DFFontHighlight" }) + GUI.ClicksButton = btnClicks + + -- ========================================================================= + -- TEST MODE BUTTON (next to CLICKS tab) + -- ========================================================================= + local btnTest = CreateFrame("Button", nil, frame, "BackdropTemplate") + -- Snapped gap + snapped width below: the toolbar is a CHAIN (Clicks <- Test + -- <- Unlock <- override marker) and controls are not position-corrected after + -- the fact, so every offset and width in the chain has to be a whole number + -- of device pixels or the fraction accumulates rightwards. + btnTest:SetPoint("LEFT", btnClicks, "RIGHT", SnapLen(btnTest, 12), 0) + GUI:StyleButton(btnTest, { + width = 75, height = 24, + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\preview_off", size = 18, color = C_TEXT_DIM }, + text = L["Test"], + }) + GUI:SetSettingsFont(btnTest.Text, 11, "") -- 11px (between Small 10 and Highlight 12) + btnTest.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + -- Content-fit width (less dead space) + btnTest:SetWidth(SnapLenUp(btnTest, math.ceil(btnTest.Text:GetStringWidth()) + 38)) + GUI.TestButton = btnTest + + -- ========================================================================= + -- LOCK/UNLOCK BUTTON (next to Test button) + -- ========================================================================= + local btnLock = CreateFrame("Button", nil, frame, "BackdropTemplate") + btnLock:SetPoint("LEFT", btnTest, "RIGHT", SnapLen(btnLock, 4), 0) + GUI:StyleButton(btnLock, { + width = 80, height = 24, + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\lock", size = 18, color = C_TEXT_DIM }, + text = L["Unlock"], + }) + GUI:SetSettingsFont(btnLock.Text, 11, "") -- 11px (between Small 10 and Highlight 12) + btnLock.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + -- Size to the wider "Unlock" label so toggling Lock/Unlock doesn't resize the + -- button (real label set in UpdateLockButtonState). + btnLock:SetWidth(SnapLenUp(btnLock, math.ceil(btnLock.Text:GetStringWidth()) + 38)) + GUI.LockButton = btnLock + + -- Position override marker (shown next to the lock button when the frame + -- position is overridden in the layout being edited). Shared marker helper → + -- dot + hover tooltip, one colour with every other override marker. + local positionOverrideStar = GUI:CreateOverrideMarker(frame, 14) + positionOverrideStar:SetPoint("LEFT", btnLock, "RIGHT", SnapLen(positionOverrideStar, 4), 0) + positionOverrideStar.tooltipText = L["Override active"] + positionOverrideStar.tooltipSubText = L["The frame position is overridden in this layout."] + GUI.PositionOverrideStar = positionOverrideStar + + -- Function to update position override indicator + local function UpdatePositionOverrideIndicator() + -- Debug mode shows indicator + if S.overrideDebugMode then + positionOverrideStar:Show() + return + end + + if GUI.SelectedMode ~= "raid" then + positionOverrideStar:Hide() + return + end + + local AutoProfilesUI = DF.AutoProfilesUI + if not AutoProfilesUI or not AutoProfilesUI:IsEditing() then + positionOverrideStar:Hide() + return + end + + -- Check if position is overridden (either X or Y) + local xOverridden = AutoProfilesUI:IsSettingOverridden("raidAnchorX") + local yOverridden = AutoProfilesUI:IsSettingOverridden("raidAnchorY") + + if xOverridden or yOverridden then + positionOverrideStar:Show() + else + positionOverrideStar:Hide() + end + end + GUI.UpdatePositionOverrideIndicator = UpdatePositionOverrideIndicator + + -- Forward declaration (defined after UpdateThemeColors) + local UpdateTestButtonState + + local function UpdateLockButtonState() + local db = DF.db[GUI.SelectedMode] + -- Raid mode uses raidLocked, party mode uses locked. Use an explicit + -- branch (NOT `a and b or c`) so an unlocked raid (raidLocked=false) + -- doesn't fall through to the party `locked` value and read as locked. + local isLocked + if db then + if GUI.SelectedMode == "raid" then isLocked = db.raidLocked else isLocked = db.locked end + end + + -- While an auto layout drives the raid frames, dragging the base position by + -- accident is the bug we're preventing: disable the toolbar Unlock and steer + -- users to the active layout's own Unlock button (Auto Layouts page). Only the + -- UNLOCK action is blocked (isLocked) — locking from here still works to finish + -- a session. + local layoutActive = (GUI.SelectedMode == "raid") and DF.AutoProfilesUI + and DF.AutoProfilesUI.IsLayoutActive and DF.AutoProfilesUI:IsLayoutActive() + if layoutActive and isLocked then + btnLock.dfDisabled = true + btnLock.Text:SetText(L["Unlock"]) + btnLock.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\lock") + btnLock:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 0.5) + btnLock:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5) + btnLock.Text:SetTextColor(0.4, 0.4, 0.4) + btnLock.Icon:SetVertexColor(0.4, 0.4, 0.4) + UpdatePositionOverrideIndicator() + return + end + btnLock.dfDisabled = false + + btnLock.Text:SetText(isLocked and L["Unlock"] or L["Lock"]) + btnLock.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. (isLocked and "lock" or "lock_open")) + + if not isLocked then + -- Unlocked - active/selected toggle look (white text/icon like the others) + btnLock:SetActive(true) + btnLock.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btnLock.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + -- Locked - normal rest (white text/icon; state shown by the border/fill) + btnLock:SetActive(false) + btnLock.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btnLock.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + + -- Update position override indicator + UpdatePositionOverrideIndicator() + end + GUI.UpdateLockButtonState = UpdateLockButtonState + + -- HookScript (not SetScript) so the disabled tooltip composes with the + -- StyleButton hover instead of clobbering it. + btnLock:HookScript("OnEnter", function(self) + if self.dfDisabled then + local name = DF.AutoProfilesUI and DF.AutoProfilesUI.GetActiveLayoutName + and DF.AutoProfilesUI:GetActiveLayoutName() + GUI:ShowTooltip(self, { + title = L["Locked by Auto Layout"], + tone = "warning", + lines = { format(L["Auto layout \"%s\" is active. Unlock it from the Auto Layouts page to move its frames."], name or "?") }, + }) + end + end) + btnLock:HookScript("OnLeave", function() GUI:HideTooltip() end) + + btnLock:SetScript("OnClick", function() + if btnLock.dfDisabled then + local name = DF.AutoProfilesUI and DF.AutoProfilesUI.GetActiveLayoutName + and DF.AutoProfilesUI:GetActiveLayoutName() + DF:Say(format(L["Auto layout \"%s\" is active. Unlock it from the Auto Layouts page to move its frames."], name or "?")) + return + end + + local db = DF.db[GUI.SelectedMode] + if not db then return end + + -- Check current lock state using the correct key per mode (explicit + -- branch — `a and b or c` would misread an unlocked raid as locked). + local isLocked + if GUI.SelectedMode == "raid" then isLocked = db.raidLocked else isLocked = db.locked end + + if GUI.SelectedMode == "raid" then + if isLocked then + DF:UnlockRaidFrames() + else + DF:LockRaidFrames() + end + else + if isLocked then + DF:UnlockFrames() + else + DF:LockFrames() + end + end + + -- Lock/Unlock functions now call UpdateLockButtonState themselves, + -- but call it here too as a safety net + UpdateLockButtonState() + UpdateTestButtonState() + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + + -- ========================================================================= + -- UI SCALE SLIDER (top right, always visible with larger min frame size) + -- ========================================================================= + local scaleContainer = CreateFrame("Frame", nil, frame) + scaleContainer:SetSize(155, 24) + scaleContainer:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -12, -32) + + local scaleLabel = scaleContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + scaleLabel:SetPoint("LEFT", 0, 0) + scaleLabel:SetText(L["UI Scale:"]) + scaleLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local scaleSlider = CreateFrame("Slider", nil, scaleContainer, "BackdropTemplate") + scaleSlider:SetPoint("LEFT", scaleLabel, "RIGHT", 6, 0) + scaleSlider:SetSize(65, 14) + scaleSlider:SetOrientation("HORIZONTAL") + scaleSlider:SetMinMaxValues(0.6, 1.4) + scaleSlider:SetValueStep(0.05) + scaleSlider:SetObeyStepOnDrag(true) + scaleSlider:SetValue(savedScale) + CreateElementBackdrop(scaleSlider) + + -- Thumb texture + local thumb = scaleSlider:CreateTexture(nil, "OVERLAY") + thumb:SetSize(12, 14) + thumb:SetColorTexture(0.5, 0.5, 0.5, 1) + scaleSlider:SetThumbTexture(thumb) + + local scaleValue = scaleContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + scaleValue:SetPoint("LEFT", scaleSlider, "RIGHT", 4, 0) + scaleValue:SetText(string.format("%.0f%%", savedScale * 100)) + scaleValue:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Only update text while dragging (not main frame scale - that causes cursor drift) + -- But DO update popup panels live + scaleSlider:SetScript("OnValueChanged", function(self, value) + value = math.floor(value * 20 + 0.5) / 20 -- Round to 0.05 + scaleValue:SetText(string.format("%.0f%%", value * 100)) + -- Update popup panels live (they don't cause cursor drift) + if DF.positionPanel then + DF.positionPanel:SetScale(value) + end + if DF.TestPanel then + DF.TestPanel:SetScale(value) + end + end) + + -- Apply scale only on mouse release to avoid cursor drift issues + scaleSlider:SetScript("OnMouseUp", function(self) + local value = math.floor(self:GetValue() * 20 + 0.5) / 20 + frame:SetScale(value) + if DF.db and DF.db.party then + DF.db.party.guiScale = value + end + -- Also update popup panels + if DF.positionPanel then + DF.positionPanel:SetScale(value) + end + if DF.TestPanel then + DF.TestPanel:SetScale(value) + end + -- A new scale changes how many device pixels a UI unit covers, so + -- every border on screen has to be re-derived at the new thickness. + -- This is the ONLY action that does: nothing else in the GUI writes + -- guiScale, and moving or resizing the window leaves it alone. + GUI:RefreshPixelBorders() + end) + + GUI.ScaleSlider = scaleSlider + GUI.ScaleContainer = scaleContainer + -- ========================================================================= + -- END TOP BAR CONTROLS + -- ========================================================================= + + local function UpdateThemeColors() + -- Mode buttons use the shared underline-tab style; SetActive drives the + -- underline + accent label (each button's per-mode accent set at creation). + btnParty:SetActive(GUI.SelectedMode == "party") + btnRaid:SetActive(GUI.SelectedMode == "raid") + btnClicks:SetActive(GUI.SelectedMode == "clicks") + + -- Test button look via the shared toggle styling (matches how the Lock + -- button is refreshed below). The old inline version painted a stray + -- theme-coloured border even at rest. + if UpdateTestButtonState then UpdateTestButtonState() end + + -- Refresh the toolbar buttons' hover wash to the current mode accent. + -- They live on the main frame (not a page child), so the page + -- ThemeListeners loop below never reaches them — without this their hover + -- stays the party colour after switching to raid. (UpdateTestButtonState/ + -- SetActive only fix the resting backdrop, not the HIGHLIGHT wash.) + if btnTest.UpdateTheme then btnTest.UpdateTheme() end + if btnLock.UpdateTheme then btnLock.UpdateTheme() end + -- infoBtn (changelog) also lives on the main frame, not a page child, so + -- the ThemeListeners loop below never reaches it either — refresh its hover + -- wash to the current mode accent here alongside Test/Lock. + if infoBtn and infoBtn.UpdateTheme then infoBtn.UpdateTheme() end + + -- Show/hide Test and Lock buttons based on mode + if GUI.SelectedMode == "clicks" then + btnTest:Hide() + btnLock:Hide() + else + btnTest:Show() + btnLock:Show() + end + + title.UpdateTheme() + + -- Update active tab + local nc = GetThemeColor() + for name, btn in pairs(GUI.Tabs) do + if btn.isActive and not btn.disabled then + btn.accent:SetColorTexture(nc.r, nc.g, nc.b, 1) + btn.Text:SetTextColor(nc.r, nc.g, nc.b) + btn.Text:SetAlpha(1) + elseif btn.disabled then + btn.Text:SetTextColor(0.4, 0.4, 0.4) + btn.Text:SetAlpha(1) + if btn.accent then btn.accent:Hide() end + end + end + + -- Update theme listeners + if GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] then + local page = GUI.Pages[GUI.CurrentPageName] + if page.child and page.child.ThemeListeners then + for _, widget in ipairs(page.child.ThemeListeners) do + if widget.UpdateTheme then widget:UpdateTheme() end + end + end + end + + -- Update test panel if open (but don't trigger circular updates) + if DF.TestPanel and DF.TestPanel:IsShown() then + DF.TestPanel:UpdateStateNoCallback() + end + + -- Update lock button state + UpdateLockButtonState() + end + GUI.UpdateThemeColors = UpdateThemeColors + + -- Function to update test button state (called externally) + UpdateTestButtonState = function() + -- Active toggle look based on whether the test panel is visible. + local testActive = DF.TestPanel and DF.TestPanel:IsShown() + btnTest:SetActive(testActive) + -- Swap the framed-eye glyph: open (preview) when test mode is showing the + -- preview frames, slashed (preview_off) when it's off. + btnTest.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + .. (testActive and "preview" or "preview_off")) + -- White text/icon in both states (state shown by the toggle border/fill). + btnTest.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btnTest.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + GUI.UpdateTestButtonState = UpdateTestButtonState + + -- Test button scripts + btnTest:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btnTest:SetScript("OnClick", function(self, button) + if button == "RightButton" then + -- Quick toggle test mode + DF:ToggleTestMode() + UpdateThemeColors() + else + -- Open/close test panel + DF:ToggleTestPanel() + UpdateTestButtonState() + end + end) + + -- Hover is handled by StyleButton (resets to grey on leave). The old manual + -- OnEnter/OnLeave left a stuck theme-coloured border because its OnLeave reset + -- to themeColor@0.5 rather than the neutral border. + + btnParty:SetScript("OnClick", function() + DF:SyncLinkedSections() + + -- Carry test mode across the mode switch (raid test -> party test) + local carryTest = false + -- Before switching tabs, clean up current mode's test mode and unlock state + if GUI.SelectedMode == "raid" then + -- Lock raid frames if unlocked + local raidDb = DF:GetRaidDB() + if not raidDb.raidLocked then + raidDb.raidLocked = true + if DF.raidContainer then + DF.raidContainer:EnableMouse(false) + DF.raidContainer:SetMovable(false) + end + if DF.LockRaidFrames then DF:LockRaidFrames() end + end + -- Disable raid test mode if active + if DF.raidTestMode then + carryTest = true + -- Hand-over: stay in container/pinned test mode across the swap rather + -- than tearing every aura container down for live data we never show + -- and rebuilding it a moment later. Cleared below. + DF._testModeHandover = true + DF:HideRaidTestFrames(true) -- silent + end + end + + GUI.SelectedMode = "party" + if DF.Search then + DF.Search:InvalidateRegistry() + DF.Search:RefreshIfActive() + end + UpdateThemeColors() + GUI:ShowNormalContent() + GUI:UpdateTabAvailability() + GUI:RefreshCurrentPage() + + -- Keep test mode active when switching modes (just switch which mode it runs in) + if carryTest and DF.ShowTestFrames then + DF:ShowTestFrames(true) -- silent + -- ShowTestFrames (unlike ShowRaidTestFrames) doesn't refresh the GUI, + -- so the test panel's toggle label would stay on "Enable Test Mode". + -- Refresh it now that party test mode is active. + if DF.TestPanel and DF.TestPanel:IsShown() then + DF.TestPanel:UpdateStateNoCallback() + end + end + if carryTest then + -- End the hand-over and settle: ShowTestFrames can bail (combat, party + -- frames disabled), which would otherwise leave the engines parked in + -- test mode with nothing testing. Teardown re-reads the real flags, so + -- it is a no-op when the incoming mode did start. + DF._testModeHandover = nil + if DF.TeardownTestModeEngines then DF:TeardownTestModeEngines() end + end + end) + btnRaid:SetScript("OnClick", function() + DF:SyncLinkedSections() + + -- Carry test mode across the mode switch (party test -> raid test) + local carryTest = false + -- Before switching tabs, clean up current mode's test mode and unlock state + if GUI.SelectedMode == "party" then + -- Lock party frames if unlocked + local partyDb = DF:GetDB() + if not partyDb.locked then + partyDb.locked = true + if DF.partyContainer then + DF.partyContainer:EnableMouse(false) + DF.partyContainer:SetMovable(false) + end + if DF.LockFrames then DF:LockFrames() end + end + -- Disable party test mode if active + if DF.testMode then + carryTest = true + -- Hand-over: see the raid->party handler above. + DF._testModeHandover = true + DF:HideTestFrames(true) -- silent + end + end + + GUI.SelectedMode = "raid" + if DF.Search then + DF.Search:InvalidateRegistry() + DF.Search:RefreshIfActive() + end + UpdateThemeColors() + GUI:ShowNormalContent() + GUI:UpdateTabAvailability() + GUI:RefreshCurrentPage() + + -- Keep test mode active when switching modes (just switch which mode it runs in) + if carryTest and DF.ShowRaidTestFrames then + DF:ShowRaidTestFrames() + end + if carryTest then + -- End the hand-over and settle; see the raid->party handler above. + DF._testModeHandover = nil + if DF.TeardownTestModeEngines then DF:TeardownTestModeEngines() end + end + end) + + -- Click Casting tab click handler + btnClicks:SetScript("OnClick", function() + -- Clean up any test/unlock state from previous mode + if GUI.SelectedMode == "party" then + local partyDb = DF:GetDB() + if partyDb and not partyDb.locked then + partyDb.locked = true + if DF.LockFrames then DF:LockFrames() end + end + if DF.testMode then DF:HideTestFrames(true) end + elseif GUI.SelectedMode == "raid" then + local raidDb = DF:GetRaidDB() + if raidDb and not raidDb.raidLocked then + raidDb.raidLocked = true + if DF.LockRaidFrames then DF:LockRaidFrames() end + end + if DF.raidTestMode then DF:HideRaidTestFrames(true) end + end + + GUI.SelectedMode = "clicks" + if DF.Search then + DF.Search:HideResults() + end + UpdateThemeColors() + GUI:ShowClickCastingContent() + end) + + -- Tab container (left side) - with scrolling + -- ★ Every offset in the nav chain is SNAPPED, for the same structural reason + -- the page viewport is: these frames are anchored by two corners, nothing + -- nudges them afterwards, and the numbers going in are the only + -- lever. Unsnapped, the whole list inherits the fraction -- /df debug navprobe + -- measured every one of 42 rows at top+0.38, which is the 4-unit inset below + -- (5.625 device px at 1.4062 px/unit) propagated down the chain. A row that + -- starts on a fractional device row draws its hover plate's top and bottom + -- edges across two rows each at partial intensity, and its label on a + -- fractional baseline -- which is what is left of the nav ghost now that the + -- dead band between rows is gone. + local tabFrame = CreateFrame("Frame", nil, frame, "BackdropTemplate") + tabFrame:SetPoint("TOPLEFT", SnapLen(frame, 12), SnapLen(frame, -64)) + tabFrame:SetPoint("BOTTOMLEFT", SnapLen(frame, 12), SnapLen(frame, 36)) + tabFrame:SetWidth(SnapLen(frame, 155)) + CreateElementBackdrop(tabFrame) + tabFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.5) + + -- ========================================================================= + -- SEARCH BAR + -- ========================================================================= + local searchBar = nil + local navPad = SnapLen(tabFrame, 4) or 4 + local navRight = SnapLen(tabFrame, -14) or -14 + local tabScrollStartY = -navPad + if DF.Search then + searchBar = DF.Search:CreateSearchBar(tabFrame) + searchBar:SetPoint("TOPLEFT", navPad, -navPad) + searchBar:SetPoint("TOPRIGHT", navRight, -navPad) + tabScrollStartY = SnapLen(tabFrame, -36) or -36 + end + + local tabScroll = CreateFrame("ScrollFrame", nil, tabFrame, "ScrollFrameTemplate") + tabScroll:SetPoint("TOPLEFT", navPad, tabScrollStartY) + tabScroll:SetPoint("BOTTOMRIGHT", navRight, navPad) + + StyleScrollBar(tabScroll) + -- Custom positioning for tab scrollbar + if tabScroll.ScrollBar then + tabScroll.ScrollBar:ClearAllPoints() + tabScroll.ScrollBar:SetPoint("TOPRIGHT", tabFrame, "TOPRIGHT", -navPad, tabScrollStartY) + tabScroll.ScrollBar:SetPoint("BOTTOMRIGHT", tabFrame, "BOTTOMRIGHT", -navPad, navPad) + end + + local tabContainer = CreateFrame("Frame", nil, tabScroll) + tabContainer:SetWidth(SnapLen(tabScroll, 130) or 130) + tabContainer:SetHeight(600) -- Will be updated dynamically + tabScroll:SetScrollChild(tabContainer) + GUI.tabContainer = tabContainer + GUI.tabScroll = tabScroll + + -- ONE hover plate for the whole nav, MOVED to the row under the cursor -- + -- rather than 42 plates that each switch themselves off and on. + -- + -- Why, after the geometry was already proven clean: navprobe showed no dead + -- band, no overlap, no stale plate, no focus thrash, and every row at + -- top-0.00. Two facts then rule out layout entirely -- the ghost is on LIVE + -- too, which shares none of this branch's GUI work, and its severity varies + -- across identical crossings. Geometry is deterministic; the same path would + -- give the same result every time. Something that varies run to run is + -- timing: the frame in which one row's colour change lands relative to the + -- next row's, which Lua cannot observe and cannot control. + -- + -- So stop relying on the two changes landing in the same frame. With a single + -- plate there is no pair to synchronise: the texture is already on screen and + -- only its anchors move, so no frame can ever show two plates or none. + local navHover = CreateFrame("Frame", nil, tabContainer, "BackdropTemplate") + navHover:EnableMouse(false) -- must never take focus from the row beneath it + -- Container level, i.e. strictly below the rows (children default to +1), so + -- the plate stays behind every label and accent bar. + navHover:SetFrameLevel(math.max(0, tabContainer:GetFrameLevel() or 1)) + CreateElementBackdrop(navHover, { outline = false, bgColor = { 0, 0, 0, 0 } }) + navHover:Hide() + GUI.navHover = navHover + + local function NavHoverShow(row, alpha) + navHover.owner = row + navHover:ClearAllPoints() + navHover:SetAllPoints(row) + navHover:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, alpha) + navHover:Show() + end + + -- Deferred by one frame ON PURPOSE. The rows tile, so leaving one and + -- entering the next happens in the same frame and WoW does not guarantee + -- which handler runs first. Hiding immediately would put back exactly the + -- dark frame this exists to remove; instead the hide only lands if no row + -- claimed the plate in the meantime. + local function NavHoverHide(row) + if navHover.owner ~= row then return end + C_Timer.After(0, function() + if navHover.owner == row then + navHover:Hide() + navHover.owner = nil + end + end) + end + GUI.HideNavHover = function() + navHover:Hide() + navHover.owner = nil + end + + + -- Content area (right side) - no BackdropTemplate in CreateFrame + -- ★ THE content panel, and therefore the ancestor of every page viewport -- + -- so its edges are the surface every page is clipped against. Snapped for + -- the same structural reason the nav chain is: it is anchored by two corners, + -- Nothing nudges it afterwards, and the offsets going in are the only + -- lever. + -- + -- The BOTTOM one is what mattered. A raw 36 is 50.625 device px at 1.4062 + -- px/unit, i.e. 0.625 above a grid line -- and /df debug pixelcheck reported + -- exactly that on every page it was ever run on: "viewport top+0.00 + -- bot-0.375". The page's own inset was already snapped, but a snapped inset + -- off a fractional PARENT edge is still fractional, so the viewport's bottom + -- clip boundary sat between two device rows. Anything resting against it -- + -- now the See-Also bar, since it was made a footer -- loses part of its + -- border to the cut. + -- + -- Same shape as the original bug at the TOP of the page: the box measures + -- perfect and the surface it is clipped against is what is wrong. Krathe + -- spotted the symmetry before the numbers did. + local content = CreateFrame("Frame", nil, frame) + content:SetPoint("TOPLEFT", tabFrame, "TOPRIGHT", SnapLen(frame, 8), 0) + content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", + SnapLen(frame, -12), SnapLen(frame, 36)) + CreateElementBackdrop(content) + content:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.3) + GUI.contentFrame = content + GUI.tabFrame = tabFrame + + -- ========================================================================= + -- CLICK CASTING PANEL (full width, replaces normal content when active) + -- ========================================================================= + local clickCastPanel = CreateFrame("Frame", nil, frame) + clickCastPanel:SetPoint("TOPLEFT", 12, -64) + clickCastPanel:SetPoint("BOTTOMRIGHT", -12, 36) + CreateElementBackdrop(clickCastPanel) + clickCastPanel:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.3) + clickCastPanel:Hide() + GUI.clickCastPanel = clickCastPanel + + -- ========================================================================= + -- FOOTER BAR (Discord & Donation links + bottom drag handle) + -- ========================================================================= + + -- Bottom drag bar (mirrors titleBar for dragging from the bottom) + local bottomBar = CreateFrame("Frame", nil, frame) + bottomBar:SetHeight(30) + bottomBar:SetPoint("BOTTOMLEFT", 0, 0) + bottomBar:SetPoint("BOTTOMRIGHT", -16, 0) -- Leave space for resize handle + bottomBar:EnableMouse(true) + bottomBar:RegisterForDrag("LeftButton") + bottomBar:SetScript("OnDragStart", function() frame:StartMoving() end) + bottomBar:SetScript("OnDragStop", function() + frame:StopMovingOrSizing() + local point, _, relPoint, x, y = frame:GetPoint() + if DF.db and DF.db.party then + DF.db.party.guiPoint = point + DF.db.party.guiRelPoint = relPoint + DF.db.party.guiX = x + DF.db.party.guiY = y + end + end) + + local footer = CreateFrame("Frame", nil, bottomBar) + footer:SetPoint("BOTTOMLEFT", 12, 8) + footer:SetPoint("BOTTOMRIGHT", -12, 8) + footer:SetHeight(22) + + -- URL copy popup helper + local function ShowURLPopup(url, label) + local popup = GUI.urlPopup + if not popup then + popup = CreateFrame("Frame", "DFURLPopup", UIParent, "BackdropTemplate") + popup:SetSize(380, 80) + popup:SetPoint("CENTER") + GUI:CreatePanelBackdrop(popup, { bgAlpha = 0.98, borderColor = C_ACCENT }) + popup:SetFrameStrata("FULLSCREEN_DIALOG") + popup:SetFrameLevel(250) + popup:EnableMouse(true) + + local popupTitle = popup:CreateFontString(nil, "OVERLAY", "DFFontNormal") + popupTitle:SetPoint("TOP", 0, -10) + popupTitle:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + popup.title = popupTitle + + local editBox = CreateFrame("EditBox", nil, popup, "BackdropTemplate") + editBox:SetPoint("TOPLEFT", 12, -30) + editBox:SetPoint("TOPRIGHT", -12, -30) + editBox:SetHeight(22) + GUI:StyleEditBox(editBox) + editBox:SetAutoFocus(true) + editBox:SetScript("OnEscapePressed", function() popup:Hide() end) + editBox:SetScript("OnEditFocusGained", function(self) self:HighlightText() end) + popup.editBox = editBox + + local hint = popup:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + hint:SetPoint("BOTTOM", 0, 8) + hint:SetText(L["Press Ctrl+C to copy, then Escape to close"]) + hint:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + GUI.urlPopup = popup + end + + popup.title:SetText(label) + popup.editBox:SetText(url) + popup:Show() + popup.editBox:SetFocus() + popup.editBox:HighlightText() + end + GUI.ShowURLPopup = ShowURLPopup + + -- Create a footer link button + local function CreateFooterLink(parent, text, color, url, popupLabel) + local btn = CreateFrame("Button", nil, parent) + btn:SetHeight(22) + + local label = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("CENTER") + label:SetText(text) + label:SetTextColor(color.r, color.g, color.b) + btn:SetWidth(label:GetStringWidth() + 10) + btn.label = label + + btn:SetScript("OnEnter", function() + local h = GUI:LinkHoverColor(color) + label:SetTextColor(h.r, h.g, h.b) + end) + btn:SetScript("OnLeave", function() + label:SetTextColor(color.r, color.g, color.b) + end) + btn:SetScript("OnClick", function() + ShowURLPopup(url, popupLabel) + end) + + return btn + end + + -- Discord link + local discordColor = { r = 0.45, g = 0.53, b = 0.85 } + local discordBtn = CreateFooterLink(footer, L["Need support? Join our Discord"], discordColor, + "https://discord.gg/SDWtduCqnT", L["Join the DandersFrames Discord"]) + discordBtn:SetPoint("LEFT", footer, "LEFT", 2, 0) + + -- Separator + local sep = footer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + sep:SetPoint("LEFT", discordBtn, "RIGHT", 8, 0) + sep:SetText("|") + sep:SetTextColor(C_BORDER.r, C_BORDER.g, C_BORDER.b) + + -- PayPal link + local paypalColor = { r = 0.35, g = 0.65, b = 0.45 } + local donateBtn = CreateFooterLink(footer, L["Support with PayPal"], paypalColor, + "https://paypal.me/dandersframesaddon", L["Support DandersFrames Development"]) + donateBtn:SetPoint("LEFT", sep, "RIGHT", 8, 0) + + -- Separator 2 + local sep2 = footer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + sep2:SetPoint("LEFT", donateBtn, "RIGHT", 8, 0) + sep2:SetText("|") + sep2:SetTextColor(C_BORDER.r, C_BORDER.g, C_BORDER.b) + + -- Patreon link + local patreonColor = { r = 0.90, g = 0.35, b = 0.30 } + local patreonBtn = CreateFooterLink(footer, L["Support with Patreon"], patreonColor, + "https://www.patreon.com/DandersFrames", L["Support DandersFrames on Patreon"]) + patreonBtn:SetPoint("LEFT", sep2, "RIGHT", 8, 0) + + -- Version on the right + local versionText = footer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + versionText:SetPoint("RIGHT", footer, "RIGHT", -2, 0) + versionText:SetText(versionStr .. channelTag) + versionText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.5) + + -- Create the click casting UI content + if DF.ClickCast then + DF.ClickCast:CreateClickCastUI(clickCastPanel) + end + + -- Store min width references for tab switching + local normalMinWidth = minWidth -- 520 + -- Shared minimum width for the "wide" pages (Binds/Click Casting, Aura + -- Designer, Text Designer, Pinned Frames) — their two-panel / tab-strip + -- layouts squash below this. + local wideMinWidth = 850 + + -- Function to show normal Party/Raid content + function GUI:ShowNormalContent() + if clickCastPanel then clickCastPanel:Hide() end + if tabFrame then tabFrame:Show() end + if content then content:Show() end + + -- Restore normal minimum width + frame:SetResizeBounds(normalMinWidth, minHeight, maxWidth, maxHeight) + + -- Update tab availability for current mode (greys out tabs for disabled modes) + GUI:UpdateTabAvailability() + end + + -- Function to show Click Casting content + function GUI:ShowClickCastingContent() + if tabFrame then tabFrame:Hide() end + if content then content:Hide() end + + -- Set larger minimum width for clicks tab + frame:SetResizeBounds(wideMinWidth, minHeight, maxWidth, maxHeight) + + -- If current width is less than clicks min, expand it + local currentWidth = frame:GetWidth() + if currentWidth < wideMinWidth then + frame:SetWidth(wideMinWidth) + end + + if clickCastPanel then + clickCastPanel:Show() + -- Refresh the spell grid + if DF.ClickCast and DF.ClickCast.RefreshSpellGrid then + DF.ClickCast:RefreshSpellGrid() + end + end + end + + -- ========================================================================= + -- SEARCH RESULTS PANEL (inside content area) + -- ========================================================================= + if DF.Search then + DF.Search:CreateResultsPanel(content) + end + + GUI.Tabs = {} + GUI.Pages = {} + + local function SelectTab(name) + -- Hide search results when navigating to a tab + if DF.Search then + DF.Search:HideResults() + end + + -- Clear any "New" section-header badges on the tab we're leaving. + -- The user has had their chance to see the badges; mark them seen + -- persistently so they don't reappear on the next visit. + local leavingTab = GUI.CurrentPageName + if leavingTab and leavingTab ~= name and GUI.pendingSectionBadges[leavingTab] then + for key, badge in pairs(GUI.pendingSectionBadges[leavingTab]) do + if badge and badge.Hide then badge:Hide() end + if DandersFramesDB_v2 then + DandersFramesDB_v2.seenSections = DandersFramesDB_v2.seenSections or {} + DandersFramesDB_v2.seenSections[key] = true + end + end + GUI.pendingSectionBadges[leavingTab] = nil + end + + for k, page in pairs(GUI.Pages) do page:Hide() end + for k, btn in pairs(GUI.Tabs) do + if btn.accent then btn.accent:Hide() end + -- Check if tab is disabled (e.g., during Auto Profile editing) + if btn.disabled then + btn.Text:SetTextColor(0.4, 0.4, 0.4) + btn.Text:SetAlpha(1) + else + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btn.Text:SetAlpha(1) + end + btn.isActive = false + btn:SetBackdropColor(0, 0, 0, 0) -- Reset background when deselected + end + + -- Auto-expand parent category so the selected tab is visible + local tab = GUI.Tabs[name] + if tab and tab.categoryName then + local cat = GUI.Categories[tab.categoryName] + if cat and not cat.expanded then + cat.expanded = true + cat.arrow:SetText("-") + -- Persist state + if DF.db and DF.db.party then + if not DF.db.party.guiExpandedCategories then + DF.db.party.guiExpandedCategories = {} + end + DF.db.party.guiExpandedCategories[cat.name] = true + end + GUI:UpdateTabLayout() + end + end + + -- Wide pages (AD/TD/Pinned) need extra width; set the resize bounds + + -- expand BEFORE building the page so its content lays out at the final + -- width instead of building narrow then staying squashed until a resize. + local WIDE_PAGES = { + auras_auradesigner = true, -- two-panel preview + controls + auras_filterdesigner = true, -- two-column preset list + spell list + text_designer = true, -- two-panel preview + controls + general_pinnedframes = true, -- tab strip + active-set meter + general_nicknames = true, -- wide add-row (Match+Char+Nick+Add) + list columns + } + if WIDE_PAGES[name] then + frame:SetResizeBounds(wideMinWidth, minHeight, maxWidth, maxHeight) + if frame:GetWidth() < wideMinWidth then + frame:SetWidth(wideMinWidth) + end + -- Belt-and-braces: re-assert the width next frame so size-dependent + -- layout settles without a manual resize. A page can build at a + -- pre-layout width (tabs overflow the panel / cards squashed); nudging + -- the width fires the same OnSizeChanged re-flows a resize would. + C_Timer.After(0, function() + if GUI.CurrentPageName ~= name or not frame:IsShown() then return end + local w = frame:GetWidth() + frame:SetWidth(w + 1) + frame:SetWidth(w) + end) + else + frame:SetResizeBounds(normalMinWidth, minHeight, maxWidth, maxHeight) + end + + if GUI.Pages[name] then + -- Set current tab for Search registration + if DF.Search then + local page = GUI.Pages[name] + DF.Search:SetCurrentTab(page.tabName, page.tabLabel) + DF.Search.CurrentSection = nil + end + + GUI.Pages[name]:Show() + -- Tab switching uses the cache-aware path so revisiting a tab is cheap. + GUI.Pages[name]:RefreshCached() + if GUI.Pages[name].RefreshStates then GUI.Pages[name]:RefreshStates() end + end + local nc = GetThemeColor() + if GUI.Tabs[name] then + if GUI.Tabs[name].accent then + GUI.Tabs[name].accent:Show() + GUI.Tabs[name].accent:SetColorTexture(nc.r, nc.g, nc.b, 1) + end + GUI.Tabs[name].Text:SetTextColor(nc.r, nc.g, nc.b) + GUI.Tabs[name].isActive = true + -- Mark "New" badge as seen + if GUI.Tabs[name].newBadge and GUI.Tabs[name].newBadge:IsShown() then + GUI.Tabs[name].newBadge:Hide() + if DandersFramesDB_v2 then + DandersFramesDB_v2.seenTabs = DandersFramesDB_v2.seenTabs or {} + DandersFramesDB_v2.seenTabs[name] = true + end + -- Hide parent category badge if no remaining children have new badges + local catName = GUI.Tabs[name].categoryName + local cat = catName and GUI.Categories[catName] + if cat and cat.newBadge and cat.newBadge:IsShown() then + local anyNew = false + for _, child in ipairs(cat.children) do + if child.newBadge and child.newBadge:IsShown() then + anyNew = true + break + end + end + if not anyNew then cat.newBadge:Hide() end + end + end + end + GUI.CurrentPageName = name + UpdateThemeColors() + end + GUI.SelectTab = SelectTab + + GUI.RefreshCurrentPage = function() + -- Don't refresh regular pages when in clicks mode (they use DF.db which doesn't have "clicks") + if GUI.SelectedMode == "clicks" then + return + end + if GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] then + GUI.Pages[GUI.CurrentPageName]:Refresh() + if GUI.Pages[GUI.CurrentPageName].RefreshStates then + GUI.Pages[GUI.CurrentPageName]:RefreshStates() + end + UpdateThemeColors() + end + -- Refresh override indicators + RefreshAllOverrideIndicators() + -- A designer preset bar can need re-reading even when its page skipped + -- the rebuild (the sharing glyph tracks the OTHER mode's preset). + if GUI.RefreshDesignerPresetBars then GUI:RefreshDesignerPresetBars() end + end + + -- Invalidate EVERY page's build cache so the next time each tab is shown it + -- rebuilds from scratch (via RefreshCached -> DoBuild). The page cache is + -- keyed on mode (party/raid) only, NOT on the active auto-layout/profile, so + -- switching between raid auto-layouts leaves cacheValid=true and tabs re-show + -- stale geometry — most visibly the Aura Designer / Text Designer frame + -- previews, which size their mock frame to the layout's frameWidth/Height at + -- build time and so stay stuck at the first-edited layout's size. Call this + -- whenever the active layout changes (enter/exit auto-profile editing). + GUI.InvalidateAllPages = function() + if not GUI.Pages then return end + for _, page in pairs(GUI.Pages) do + if page.Invalidate then page:Invalidate() end + end + end + + -- Category system + GUI.Categories = {} + local categoryY = -8 + + local function CreateCategory(name, label) + local cat = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") + cat:SetPoint("TOPLEFT", 4, categoryY) + cat:SetPoint("TOPRIGHT", -4, categoryY) + -- Placeholder only: UpdateTabLayout owns the real height and sets it to + -- the row STRIDE so the rows tile with no dead band between them (see the + -- comment there -- that band was the hover flash). Matched here so the + -- row is never briefly the wrong size before the first layout pass. + cat:SetHeight(SnapLen(cat, 30) or 30) + -- Hover plate only: transparent at rest, tinted by OnEnter/OnLeave. + CreateElementBackdrop(cat, { outline = false, bgColor = { 0, 0, 0, 0 } }) + cat.name = name + cat.children = {} + + -- Restore saved state (default collapsed) + local savedStates = DF.db and DF.db.party and DF.db.party.guiExpandedCategories + cat.expanded = savedStates and savedStates[name] or false + + -- Expand/collapse indicator (simple minus/plus) + cat.arrow = cat:CreateFontString(nil, "OVERLAY", "DFFontNormal") + cat.arrow:SetPoint("LEFT", 6, 0) + cat.arrow:SetText(cat.expanded and "-" or "+") + cat.arrow:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + cat.Text = cat:CreateFontString(nil, "OVERLAY", "DFFontNormal") + cat.Text:SetPoint("LEFT", 20, 0) + cat.Text:SetText(label) + cat.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- "New" badge for categories — shown when any child tab has a new badge + local catNewBadge = cat:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + catNewBadge:SetPoint("RIGHT", cat, "RIGHT", -8, 0) + catNewBadge:SetText(L["New"]) + catNewBadge:SetTextColor(1, 0.82, 0) + catNewBadge:Hide() + cat.newBadge = catNewBadge + + -- Hover is the SHARED moving plate, not this row's own backdrop -- see + -- navHover. The row's backdrop stays permanently transparent (SelectTab + -- still resets it, harmlessly). + cat:SetScript("OnEnter", function(self) NavHoverShow(self, 0.3) end) + cat:SetScript("OnLeave", function(self) NavHoverHide(self) end) + cat:SetScript("OnClick", function(self) + self.expanded = not self.expanded + self.arrow:SetText(self.expanded and "-" or "+") + -- Persist state + if DF.db and DF.db.party then + if not DF.db.party.guiExpandedCategories then + DF.db.party.guiExpandedCategories = {} + end + DF.db.party.guiExpandedCategories[self.name] = self.expanded or nil + end + GUI:UpdateTabLayout() + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + + GUI.Categories[name] = cat + -- Only add to CategoryOrder if not already in the explicit list (Options.lua sets it) + local found = false + for _, v in ipairs(GUI.CategoryOrder) do + if v == name then found = true break end + end + if not found then + tinsert(GUI.CategoryOrder, name) + end + categoryY = categoryY - 30 + return cat + end + + -- `hidden`: build and register the page exactly as normal, but keep its row + -- OUT of the sidebar. For a page that is DEPRECATED but not yet deleted -- + -- the code stays whole and greppable, GUI.Pages/GUI.Tabs still resolve so + -- nothing that walks them has to special-case it, and deleting one word at + -- the call site puts the page back. See DEPRECATED-TARGETED-SPELLS. + local function CreateSubTab(categoryName, name, label, hidden) + local cat = GUI.Categories[categoryName] + if not cat then return end + + local btn = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") + btn:SetHeight(SnapLen(btn, 28) or 28) -- placeholder; see CreateCategory + -- Hover/selected plate only: the accent bar carries the selected state. + CreateElementBackdrop(btn, { outline = false, bgColor = { 0, 0, 0, 0 } }) + btn.isTab = true + btn.tabName = name + btn.categoryName = categoryName + + -- Left accent bar + btn.accent = btn:CreateTexture(nil, "OVERLAY") + btn.accent:SetPoint("TOPLEFT", 0, 0) + btn.accent:SetPoint("BOTTOMLEFT", 0, 0) + btn.accent:SetWidth(3) + btn.accent:Hide() + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 24, 0) + btn.Text:SetText(label) + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- "New" badge — shown for tabs in GUI.NewTabs until the user opens them + local newBadge = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + newBadge:SetPoint("RIGHT", btn, "RIGHT", -8, 0) + newBadge:SetText(L["New"]) + newBadge:SetTextColor(1, 0.82, 0) + newBadge:Hide() + btn.newBadge = newBadge + if GUI.NewTabs[name] + and not (DandersFramesDB_v2 and DandersFramesDB_v2.seenTabs + and DandersFramesDB_v2.seenTabs[name]) then + newBadge:Show() + -- Also show "New" on the parent category + if cat and cat.newBadge then + cat.newBadge:Show() + end + end + + -- Shared moving plate (see navHover). The active tab still shows no hover + -- tint -- its accent bar and label colour are its state -- so crossing it + -- parks the plate rather than moving it. + btn:SetScript("OnEnter", function(self) + if not self.isActive then NavHoverShow(self, 0.5) end + end) + btn:SetScript("OnLeave", function(self) NavHoverHide(self) end) + btn:SetScript("OnClick", function(self) + if self.disabled then return end + SelectTab(name) + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + + -- Create the page + local page = CreateFrame("ScrollFrame", nil, content, "ScrollFrameTemplate") + -- ★ The insets are SNAPPED, and this is the frame the whole pixel grid + -- was hanging off. A ScrollFrame clips to its own rect, so its top edge is + -- the boundary every box near the top of a page is cut against -- and a + -- raw 8-unit inset is 11.25 DEVICE PIXELS at 0.75 scale, i.e. a quarter + -- pixel off the grid. /df debug pixelcheck measured exactly that: viewport + -- top-0.25, scroll child top-0.25 (it inherits the phase), while every + -- box inside reported a flawless top+0.00. The boxes were never the + -- problem; the surface they are clipped against was. + -- + -- Nothing corrects it afterwards -- this frame is anchored by TWO + -- corners, so even the geometry correction that used to exist skipped it + -- (nudging a two-corner frame resizes it rather than moving it). The one + -- surface every page is measured against was the one it could not touch. + -- Snapping the OFFSETS is the fix that works for a two-corner frame: + -- correct the numbers going in, since the box itself can't be nudged. + local inset = SnapLen(content, 8) or 8 + page:SetPoint("TOPLEFT", inset, -inset) + page:SetPoint("BOTTOMRIGHT", -inset, inset) + + StyleScrollBar(page) + + local child = CreateFrame("Frame", nil, page) + -- Snapped for the same reason: content is positioned against this child, + -- so a fractional width put every right edge inside it off-grid too + -- (pixelcheck reported w-0.16). + child:SetSize(SnapLen(page, (content:GetWidth() or 0) - 30) or 1, 1) + page:SetScrollChild(child) + page.child = child + page.tabName = name + page.tabLabel = label + page:Hide() + page.Refresh = function() end + + GUI.Tabs[name] = btn + GUI.Pages[name] = page + if hidden then + -- Staying out of cat.children is what actually hides it: UpdateTabLayout + -- only ever anchors and sizes that list, so this row is never given a + -- position. Hidden explicitly too, so nothing rests on that detail. + btn:Hide() + else + table.insert(cat.children, btn) + end + + return page + end + + -- Update tab positions based on expanded/collapsed state + function GUI:UpdateTabLayout() + -- Every number here is snapped, and the running `y` is built ONLY out of + -- snapped steps, so each row lands on the pixel grid rather than each one + -- picking up a different sub-pixel phase down the list. The nav rows are + -- two-corner anchored, so nothing downstream could correct + -- them (and would not anyway -- they are not structural boxes): the + -- offsets going in are the only lever, same as the page viewport. + -- + -- The rows TILE: each one's height IS the stride to the next, so there is + -- no strip between them where the cursor is over nothing. + -- + -- They used to be 28 tall on a 30 stride (and 26 on 28), leaving a 2-unit + -- dead band -- about 3 device pixels -- between every pair. /df debug navprobe + -- caught what that costs: crossing the band puts mouse focus on the plain + -- container Frame behind the list for a single frame, with NO row lit, so + -- the hover plate blinks off and back on mid-sweep. That one dark frame is + -- the "ghost" -- and because whether you land in the band depends on how + -- fast you are moving, the same crossing sometimes flashed and sometimes + -- did not, which is why it looked like a render hitch rather than layout. + -- + -- Tiling costs nothing visually: only one row is ever lit, so the plates + -- have no neighbour to sit flush against. The height is taken FROM the + -- stride rather than snapped separately, so they cannot disagree by a + -- pixel and reopen a hairline gap. + -- Park the shared hover plate: expanding or collapsing can hide the row it + -- is anchored to, and a plate anchored to a hidden frame is a stray. + if GUI.HideNavHover then GUI.HideNavHover() end + + local container = GUI.tabContainer + local y = SnapLen(container, -8) or -8 + local catStride = SnapLen(container, 30) or 30 + local tabStride = SnapLen(container, 28) or 28 + + for _, catName in ipairs(GUI.CategoryOrder) do + local cat = GUI.Categories[catName] + if cat then + cat:ClearAllPoints() + cat:SetPoint("TOPLEFT", 0, y) + cat:SetPoint("TOPRIGHT", 0, y) + cat:SetHeight(catStride) + y = y - catStride + + if cat.expanded then + for _, btn in ipairs(cat.children) do + -- Party-only tabs are hidden entirely in raid mode. + if btn.partyOnly and GUI.SelectedMode == "raid" then + btn:Hide() + else + btn:Show() + btn:ClearAllPoints() + btn:SetPoint("TOPLEFT", 0, y) + btn:SetPoint("TOPRIGHT", 0, y) + btn:SetHeight(tabStride) + y = y - tabStride + end + end + else + for _, btn in ipairs(cat.children) do + btn:Hide() + end + end + end + end + + -- Update scroll child height + local totalHeight = math.abs(y) + 20 + GUI.tabContainer:SetHeight(totalHeight) + end + + -- Re-sync each category's expanded state from the current profile's saved + -- state and relayout the sidebar. Categories read their state once at + -- creation, so a profile switch needs this to reflect the new profile's + -- expanded/collapsed tabs without a /reload. + function GUI:RefreshCategoryStates() + local saved = DF.db and DF.db.party and DF.db.party.guiExpandedCategories + for name, cat in pairs(self.Categories) do + cat.expanded = (saved and saved[name]) or false + if cat.arrow then cat.arrow:SetText(cat.expanded and "-" or "+") end + end + self:UpdateTabLayout() + end + + -- Store category order + GUI.CategoryOrder = {} + + -- (Removed) CreateTab — "legacy single tab support", a thin wrapper over + -- CreateSubTab("tools", ...). Every page registers through CreateSubTab directly + -- now, so it had no callers. + + local function BuildPage(page, builderFunc) + -- Internal: construct all widget frames for the current mode. + -- Called on first visit and whenever the cache is invalidated. + -- Always finishes by calling RefreshStates() so callers don't need to. + local function DoBuild(self) + local db = DF.db[GUI.SelectedMode] + if not db then return end + + -- Retire old children: hide, detach anchors, and reparent to the + -- trash frame so they leave the GUI frame hierarchy entirely. + -- WoW cannot GC frames, but a detached subtree is not traversed + -- during drag layout recalculation. + if self.children then + local trash = GUI._trashFrame + for _, child in ipairs(self.children) do + child:Hide() + child:ClearAllPoints() + if trash then child:SetParent(trash) end + end + end + self.children = {} + self.child.ThemeListeners = {} + -- Propagate RefreshStates to child so widgets can call it + self.child.RefreshStates = function() self:RefreshStates() end + local parent = self.child + + -- col = 1, 2, or "both". THE PAGE LAYOUT STANDARD lives here, because + -- this is the call that decides it and the only thing every page has + -- in common: + -- + -- column 1 -- structure and geometry, in this order: + -- Settings -> [Layout] -> [Size] -> Position + -- column 2 -- styling, in this order: + -- Appearance -> Border -> element extras (text, bars) + -- "both" -- ONLY on a page that genuinely needs the width + -- (Pinned Frames, Nicknames). It is also a sync point: + -- it takes the lower of the two columns and drops both + -- to it, whether or not you wanted that. + -- + -- Columns rather than a flat order, because a page is read as two + -- columns and not as a sequence -- putting Appearance third and + -- Border fifth still separates them on screen if they land on + -- opposite sides. Grouping by column keeps the two natural pairs + -- (Layout+Position, Appearance+Border) together where the eye is. + -- + -- ⚠ The split only MEANS anything where both categories are present. + -- A surface holding nothing but geometry (an aura page's Layout + -- section) or nothing but styling (its Appearance section) has no + -- left/right distinction to preserve, so its boxes fill both columns + -- for balance and reading order instead. Forcing the rule there + -- empties one column, which is worse than the inconsistency it was + -- meant to fix. + -- + -- Sections (GUI:CreateCollapsibleSection) hold a page's boxes when + -- the page needs a second level -- either PARALLEL SUB-FEATURES + -- (Icons, Highlights, Health Bar) or broad CATEGORIES whose box + -- names only make sense inside them (the aura pages, where Layout + -- and Duration Bar each contain their own "Settings" box). The rule + -- above then applies within each section rather than across the + -- page. A page that needs neither stays as plain boxes. + local function Add(widget, height, col) + table.insert(self.children, widget) + widget:SetParent(parent) + widget.layoutHeight = ResolveRowHeight(widget, height) + widget.layoutCol = col or 1 + return widget + end + + -- Disabled-mode handling: when the current mode is off in General + -- settings, replace the page content with a single banner instead + -- of rendering any controls. Whitelisted pages (General, Profiles, + -- Debug, Targeted List, Personal Targeted) always render normally. + if GUI:IsTabDisabledForCurrentMode(self.tabName) then + local banner = GUI:CreateInfoBanner(parent, { tone = "info" }) + banner:SetText(GUI.SelectedMode == "raid" + and (L["Raid frames are currently disabled. Changes here will apply after re-enabling Raid in the General tab and reloading."]) + or (L["Party frames are currently disabled. Changes here will apply after re-enabling Party in the General tab and reloading."])) + table.insert(self.children, banner) + banner.layoutCol = "both" + self.builtForMode = GUI.SelectedMode + self.builtForDisabled = true + self.cacheValid = true + self:RefreshStates() + return + end + + local function AddSpace(h, col) + local spacer = CreateFrame("Frame", nil, parent) + spacer:SetSize(1, h) + spacer.layoutHeight = h + spacer.layoutCol = col or "both" + table.insert(self.children, spacer) + return spacer + end + + -- Sync point: forces both columns to align to the same Y position + local function AddSyncPoint() + local sync = CreateFrame("Frame", nil, parent) + sync:SetSize(1, 1) + sync.isSyncPoint = true + sync.layoutHeight = 0 + sync.layoutCol = "both" + table.insert(self.children, sync) + end + + builderFunc(self, db, Add, AddSpace, AddSyncPoint) + self.builtForMode = GUI.SelectedMode + self.builtForDisabled = false + self.cacheValid = true + self:RefreshStates() + end + + -- Invalidate this page's cache so the next RefreshCached() rebuilds. + page.Invalidate = function(self) + self.cacheValid = false + self.builtForMode = nil + end + + -- Cache-aware refresh — used ONLY by tab switching. If the cached build + -- is still valid for the current mode and enabled/disabled state, run the + -- cheap visibility/layout pass; otherwise rebuild. This is the perf path + -- that makes revisiting a tab cheap. + page.RefreshCached = function(self) + local db = DF.db[GUI.SelectedMode] + -- Guard against nil db (e.g., when "clicks" mode is selected) + if not db then return end + + local isDisabled = GUI:IsTabDisabledForCurrentMode(self.tabName) + if self.cacheValid + and self.builtForMode == GUI.SelectedMode + and self.builtForDisabled == isDisabled then + self:RefreshStates() + return + end + + -- Cache miss: build fresh for this mode. + -- DoBuild sets cacheValid and calls RefreshStates() before returning. + DoBuild(self) + end + + -- Refresh() ALWAYS rebuilds. This is its historical contract: callers + -- invoke it after mutating data (adding/removing list items, reset/copy/ + -- sync, profile changes, etc.) and rely on the page being reconstructed. + -- Only tab switching uses the cache, via RefreshCached(). + page.Refresh = function(self) + local db = DF.db[GUI.SelectedMode] + -- Guard against nil db (e.g., when "clicks" mode is selected) + if not db then return end + DoBuild(self) + end + + page.RefreshStates = function(self) + if not self.children then return end + local db = DF.db[GUI.SelectedMode] + if not db then return end + + -- First pass: handle SettingsGroups - layout their children and calculate heights + for _, widget in ipairs(self.children) do + if widget.isSettingsGroup then + -- Layout children within the group (handles hideOn internally) + widget:LayoutChildren() + -- Process disableOn for group children + widget:RefreshChildStates() + end + end + + -- Second pass: handle regular widgets and group visibility + for _, widget in ipairs(self.children) do + -- Keep any blocked-overlay (top-level widget or group) in sync. + -- Skip SettingsGroup children - they're handled by their parent group + if widget.settingsGroup then + -- Already handled by group's LayoutChildren + elseif widget.isSettingsGroup then + -- For groups, check collapsible section state AND group-level hideOn + local shouldHide = false + + -- Check if parent collapsible section is collapsed + if widget.collapsibleSection and not widget.collapsibleSection.expanded then + shouldHide = true + end + + -- Check group's own hideOn + if not shouldHide and widget.hideOn then + shouldHide = widget.hideOn(db) + end + + if shouldHide then + widget:Hide() + else + widget:Show() + end + else + -- Regular widget processing + if widget.disableOn then + local shouldDisable = widget.disableOn(db) + if widget.SetEnabled then + widget:SetEnabled(not shouldDisable) + end + end + + -- Check if widget should be hidden + local shouldHide = false + + -- First check if parent collapsible section is collapsed + if widget.collapsibleSection and not widget.collapsibleSection.expanded then + shouldHide = true + end + + -- Then check widget's own hideOn + if not shouldHide and widget.hideOn then + shouldHide = widget.hideOn(db) + end + + if shouldHide then + widget:Hide() + else + widget:Show() + -- Call refreshContent hook for dynamic content updates + if widget.refreshContent then + widget:refreshContent(db) + end + end + end + end + + -- Determine column layout based on content area width. + -- Two-column settings groups are 280px wide and placed at x=5 (right + -- edge 285), while column 2 starts at contentWidth/2 — so they begin to + -- overlap once contentWidth drops below ~570. minColumnWidth must exceed + -- the 280 group width (was a stale 270) so the layout collapses to one + -- column BEFORE the columns touch, leaving a ~10px gutter at the cutover + -- instead of overlapping for the last ~10px. + local contentWidth = GUI.contentFrame and GUI.contentFrame:GetWidth() or 540 + local minColumnWidth = 285 -- ≥ the 280 group width + a small gutter + local usesTwoColumns = contentWidth >= (minColumnWidth * 2 + 20) + + -- Account for scrollbar and padding when calculating usable width + local usableWidth = contentWidth - 40 -- Extra padding for scrollbar + + -- Check if editing banner is active (adds 50px at top) + local bannerOffset = 0 + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + bannerOffset = 50 + end + + -- Layout - adjust column positions based on available width + local x1, maxY = 5, 0 + local col2X = usesTwoColumns and math.floor(contentWidth / 2) or x1 + local y1, y2 = -5 - bannerOffset, -5 - bannerOffset + + -- First, position any right-aligned elements (like Copy buttons) at absolute top-right + for _, widget in ipairs(self.children) do + if widget.rightAlign and widget:IsShown() then + widget:ClearAllPoints() + -- Both offsets snapped: this widget is the HEAD of the + -- Reset/Sync/Copy chain, so a fraction here is inherited by + -- every button to its left, and the buttons are no longer + -- nudged back onto the grid individually. + widget:SetPoint("TOPRIGHT", self.child, "TOPRIGHT", + SnapLen(self.child, -10), SnapLen(self.child, -5 - bannerOffset)) + end + end + + -- Reserve space below the right-aligned row (Reset Page / Sync / Copy). + -- + -- This used to subtract a flat 40 with the comment "button height ~26 + + -- 14 padding" -- a GUESS, not a measurement. Any page whose row is not + -- exactly 26 tall got a different gap under it, so the first box sat at + -- a different height on every page and the row appeared to shift. + -- Measure the row instead and add a fixed pad, so the gap below the + -- buttons is identical everywhere by construction. + -- + -- The scan is ALSO gated on IsShown() now, matching the positioning loop + -- above. It was not, so a page carrying a HIDDEN right-aligned widget + -- still reserved the 40 and opened with an empty band above its first + -- box -- the pages Krathe saw "starting further down" with nothing there. + local RIGHT_ROW_PAD = 14 + local rightRowH = 0 + for _, widget in ipairs(self.children) do + if widget.rightAlign and widget:IsShown() then + rightRowH = math.max(rightRowH, widget:GetHeight() or 0) + end + end + if rightRowH > 0 then + -- Snapped for the same reason every other layout number is: an + -- unsnapped offset puts everything below it off the pixel grid. + local reserve = SnapLen(self.child, rightRowH + RIGHT_ROW_PAD) + y1 = y1 - reserve + y2 = y2 - reserve + end + + for _, widget in ipairs(self.children) do + -- Skip widgets that belong to a SettingsGroup (they're positioned by the group) + if widget.settingsGroup then + -- Do nothing - parent group handles positioning + elseif widget.rightAlign then + -- Already positioned above, skip + elseif widget.isSyncPoint then + -- Sync point: align both columns to the lowest Y position + local syncY = math.min(y1, y2) + y1 = syncY + y2 = syncY + elseif widget:IsShown() then + -- For SettingsGroups, use calculated height + local h = widget.layoutHeight or 0 + if widget.isSettingsGroup and widget.calculatedHeight then + h = widget.calculatedHeight + end + + widget:ClearAllPoints() + + -- Set height for frame-based widgets (like header containers) + if widget.text and widget.SetHeight and h > 0 then + widget:SetHeight(h) + end + + -- Apply indent offset if specified (for child/sub-options) + -- Supports: true (20px), or a number for multiple levels (e.g. 2 = 40px) + local indentOffset = 0 + if widget.indent then + if type(widget.indent) == "number" then + indentOffset = widget.indent * 20 + else + indentOffset = 20 + end + end + + -- Snap the offsets and widths this loop hands out, for the same + -- reason CreateSettingsGroup:LayoutChildren does: a widget placed + -- straight onto the page (a banner, a full-width note, a + -- collapsible section) never passes through a group, so this is + -- the only place its geometry can be put on the grid. See SnapLen. + local snapX = SnapLen(widget, x1 + indentOffset) + + if widget.layoutCol == "both" then + local startY = math.min(y1, y2) + widget:SetPoint("TOPLEFT", snapX, SnapLen(widget, startY)) + -- Set width to span both columns (with scrollbar padding) + widget:SetWidth(SnapLen(widget, usableWidth - indentOffset)) + y1 = startY - h + y2 = startY - h + elseif widget.layoutCol == 2 and usesTwoColumns then + widget:SetPoint("TOPLEFT", SnapLen(widget, col2X + indentOffset), + SnapLen(widget, y2)) + -- Reduce width for indented widgets to maintain alignment + if indentOffset > 0 and widget.SetWidth then + local defaultColWidth = math.floor((usableWidth - 20) / 2) + widget:SetWidth(SnapLen(widget, defaultColWidth - indentOffset)) + end + y2 = y2 - h + else + -- Column 1, or column 2 when in single-column mode + widget:SetPoint("TOPLEFT", snapX, SnapLen(widget, y1)) + -- Reduce width for indented widgets to maintain alignment + if indentOffset > 0 and widget.SetWidth then + local defaultColWidth = math.floor((usableWidth - 20) / 2) + widget:SetWidth(SnapLen(widget, defaultColWidth - indentOffset)) + end + y1 = y1 - h + end + + local currentBottom = math.min(y1, y2) + if math.abs(currentBottom) > maxY then maxY = math.abs(currentBottom) end + end + end + local contentH = maxY + 40 + bannerOffset + + -- FOOTER: the See-Also bar is designed as one, so on a page whose + -- content does not fill the viewport it should sit at the BOTTOM + -- rather than floating halfway down with dead space under it. On a + -- page that does fill (or overflow) the viewport it already lands at + -- the end of the flow, which reads correctly -- so this only moves it + -- when there is spare room, and the long-page case is untouched. + -- + -- Done by stretching the scroll child to the viewport height and + -- anchoring the footer to the child's BOTTOM: the child is what the + -- flow is measured against, so this keeps the footer inside the + -- scrolled content (it still scrolls with a long page) instead of + -- floating over it. + local footer + for _, w in ipairs(self.children) do + if w.isPageFooter and w:IsShown() then footer = w break end + end + if footer then + local viewH = self:GetHeight() or 0 + if viewH > contentH then + contentH = viewH + footer:ClearAllPoints() + footer:SetPoint("BOTTOMLEFT", self.child, "BOTTOMLEFT", + SnapLen(self.child, x1), SnapLen(self.child, GUI.Space.footer)) + footer:SetWidth(SnapLen(footer, usableWidth)) + end + end + + self.child:SetHeight(contentH) + + -- Update scroll child width to match content area + if self.child and GUI.contentFrame then + self.child:SetWidth(GUI.contentFrame:GetWidth() - 30) + end + end + end + + -- Trash frame: detached from the GUI hierarchy. Old page children are + -- reparented here on rebuild so they don't contribute to the frame + -- traversal cost during window drag layout recalculation. + GUI._trashFrame = CreateFrame("Frame") + GUI._trashFrame:Hide() + + -- Invalidate all page caches (call before profile/mode switches so each + -- page rebuilds with a fresh db reference on its next visit). + function GUI:InvalidateAllPages() + for _, page in pairs(self.Pages) do + if page.Invalidate then page:Invalidate() end + end + end + + -- Invalidate a single page by name. + function GUI:InvalidatePage(name) + local page = self.Pages[name] + if page and page.Invalidate then page:Invalidate() end + end + + -- Load pages from Options file + if DF.SetupGUIPages then + DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) + end + + -- Setup Auto Profiles editing banner + if DF.AutoProfilesUI and DF.AutoProfilesUI.SetupEditingBanner then + DF.AutoProfilesUI:SetupEditingBanner() + end + + -- Update tab layout after all tabs created + GUI:UpdateTabLayout() + + UpdateThemeColors() + + -- Apply tab availability for current mode (greys out disabled-mode tabs) + GUI:UpdateTabAvailability() + + -- Select first subtab + if GUI.CategoryOrder[1] then + local firstCat = GUI.Categories[GUI.CategoryOrder[1]] + if firstCat and firstCat.children[1] then + SelectTab(firstCat.children[1].tabName) + end + end +end diff --git a/GUI/Sections.lua b/GUI/Sections.lua new file mode 100644 index 00000000..67410167 --- /dev/null +++ b/GUI/Sections.lua @@ -0,0 +1,1597 @@ +-- Part 2 of the GUI toolkit, split from the original GUI.lua. +-- These re-declarations are aliases of the SAME objects the first part +-- created; they add no state. See docs/reorg-tools/splits.manifest. +local addonName, DF = ... +local GUI = DF.GUI +local L = DF.L +local S = GUI._state +local P = GUI._priv +local C_BACKGROUND, C_TEXT, C_TEXT_DIM = + GUI.Colors.background, GUI.Colors.text, GUI.Colors.textDim +local ResolveRowHeight = GUI.ResolveRowHeight +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local CreateElementBackdrop = GUI.CreateElementBackdrop + +function GUI:CreateSettingsGroup(parent, width, opts) + -- opts can be a boolean (legacy: collapsible) or a table { collapsible, showSummary, onCollapseChanged } + if type(opts) == "boolean" then opts = { collapsible = opts } end + opts = opts or {} + + local group = CreateFrame("Frame", nil, parent, "BackdropTemplate") + group.onCollapseChanged = opts.onCollapseChanged + group:SetSize(width or 280, 10) -- Height will be calculated dynamically + group.groupChildren = {} + group.isSettingsGroup = true + group.collapsible = opts.collapsible or false + group.showSummary = opts.showSummary or false + -- Optional saved-state key override: lets several boxes share a standard + -- display header (e.g. "Appearance") while persisting collapse state under a + -- unique key (e.g. "afkIcon:Appearance"), so they don't toggle together. + group.collapseKey = opts.collapseKey + group.collapsed = false + + -- Visual styling - subtle background and border + local padding = 10 + local margin = 10 -- Space between groups + group.padding = padding + group.margin = margin + + -- 8%, and worth knowing why it is not 16%: it was, for a while, because an + -- 8% edge that split across two device rows left 4% on each and neither was + -- visible. Doubling it made the surviving half readable at the cost of the + -- whole border reading too heavy when it did NOT split. The border no longer + -- has to survive being split, because it no longer splits -- so the alpha + -- went back to the value that was right in the first place. + CreateElementBackdrop(group, { + bgColor = { 1, 1, 1, 0.03 }, -- very subtle white background (3%) + borderColor = { 1, 1, 1, 0.08 }, -- subtle white border (8%) + }) + + -- Bottom collapse bar (only for collapsible groups, shown when expanded) + if group.collapsible then + local collapseBar = CreateFrame("Button", nil, group) + collapseBar:SetHeight(14) + collapseBar:SetPoint("BOTTOMLEFT", group, "BOTTOMLEFT", 1, 1) + collapseBar:SetPoint("BOTTOMRIGHT", group, "BOTTOMRIGHT", -1, 1) + + local barBg = collapseBar:CreateTexture(nil, "BACKGROUND") + barBg:SetAllPoints() + barBg:SetColorTexture(1, 1, 1, 0.03) + + local barIcon = collapseBar:CreateTexture(nil, "OVERLAY") + barIcon:SetSize(12, 12) + barIcon:SetPoint("CENTER", 0, 0) + local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + -- "expand_more" is a down chevron; rotate 180° so it points UP — this bar + -- collapses the (expanded) section, so an up arrow reads correctly. + barIcon:SetTexture(mediaPath .. "expand_more") + barIcon:SetRotation(math.pi) + barIcon:SetVertexColor(1, 1, 1, 0.5) + + collapseBar:SetScript("OnEnter", function() + barBg:SetColorTexture(1, 1, 1, 0.06) + barIcon:SetVertexColor(1, 1, 1, 0.85) + end) + collapseBar:SetScript("OnLeave", function() + barBg:SetColorTexture(1, 1, 1, 0.03) + barIcon:SetVertexColor(1, 1, 1, 0.5) + end) + collapseBar:SetScript("OnClick", function() + group.collapsed = true + local headerText = group.headerWidget and group.headerWidget.text and group.headerWidget.text:GetText() + local stateKey = group.collapseKey or headerText + if stateKey then + local saved = GUI:GetCollapsedGroups() + saved[stateKey] = true + end + if group.collapseArrow then + group.collapseArrow:SetTexture(mediaPath .. "chevron_right") + end + if DF.AuraDesigner_RefreshPage then + DF:AuraDesigner_RefreshPage() + end + local pageChild = group:GetParent() + if pageChild and pageChild.RefreshStates then pageChild.RefreshStates() end + if group.onCollapseChanged then group.onCollapseChanged(group) end + end) + + collapseBar:Hide() + group.collapseBar = collapseBar + end + + -- Add a widget to this group + group.AddWidget = function(self, widget, height) + widget:SetParent(self) + -- Record whether the CALL SITE pinned this slot's height. A self-measuring + -- widget (CreateLabel) may only re-flow the group when it did not — an + -- explicit number stays authoritative, so no existing layout can shift. + widget._slotHeightExplicit = (height ~= nil) or nil + table.insert(self.groupChildren, { + widget = widget, + height = ResolveRowHeight(widget, height), + }) + -- Mark widget as belonging to this group + widget.settingsGroup = self + + -- If collapsible and this is the first widget (header), set up collapse toggle + if self.collapsible and #self.groupChildren == 1 and widget.text then + self.headerWidget = widget + + -- Resolve collapsed state: default to expanded unless saved state says collapsed + local headerText = widget.text:GetText() + local stateKey = self.collapseKey or headerText + local savedStates = GUI:GetCollapsedGroups() + if stateKey and savedStates[stateKey] then + self.collapsed = true + else + self.collapsed = false + end + + -- Shift header text right to make room for the arrow icon + widget.text:ClearAllPoints() + widget.text:SetPoint("BOTTOMLEFT", widget, "BOTTOMLEFT", 14, 2) + + -- Add toggle arrow icon (texture from Media folder) + local arrow = widget:CreateTexture(nil, "OVERLAY") + arrow:SetSize(10, 10) + arrow:SetPoint("RIGHT", widget.text, "LEFT", -2, 0) + local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + arrow:SetTexture(self.collapsed and (mediaPath .. "chevron_right") or (mediaPath .. "expand_more")) + local c = GetThemeColor() + arrow:SetVertexColor(c.r, c.g, c.b) + self.collapseArrow = arrow + + -- Theme listener for arrow color + arrow.UpdateTheme = function() + local nc = GetThemeColor() + arrow:SetVertexColor(nc.r, nc.g, nc.b) + end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, arrow) + + -- Make the header clickable + widget:EnableMouse(true) + widget:SetScript("OnMouseDown", function() + self.collapsed = not self.collapsed + -- Persist collapsed state to SavedVariables + if stateKey then + local saved = GUI:GetCollapsedGroups() + saved[stateKey] = self.collapsed or nil -- only store true, remove when expanded + end + arrow:SetTexture(self.collapsed and (mediaPath .. "chevron_right") or (mediaPath .. "expand_more")) + -- Refresh the page to recalculate layout. The Aura Designer page + -- has its own refresh; BuildPage pages (icons, frame settings…) + -- expose RefreshStates on the group's parent (self.child). + if DF.AuraDesigner_RefreshPage then + DF:AuraDesigner_RefreshPage() + end + local pageChild = self:GetParent() + if pageChild and pageChild.RefreshStates then pageChild.RefreshStates() end + if self.onCollapseChanged then self.onCollapseChanged(self) end + end) + + -- Highlight arrow on hover to indicate clickable + widget:SetScript("OnEnter", function() + arrow:SetVertexColor(1, 1, 1) + end) + widget:SetScript("OnLeave", function() + local nc = GetThemeColor() + arrow:SetVertexColor(nc.r, nc.g, nc.b) + end) + end + + return widget + end + + -- Calculate total height based on visible children and layout them + group.LayoutChildren = function(self) + -- Snapped padding: every child's left edge and first row start from it, so + -- if it is a fractional number of device pixels the whole column inherits + -- that offset. See SnapLen. + local padding = SnapLen(self, self.padding) + local y = -padding -- Start with top padding + local visibleCount = 0 + -- Width for child widgets. A group whose width is not resolved yet (created but + -- not laid out, or anchors cleared) yields a non-positive innerWidth. Do NOT + -- substitute a guessed width — a group can legitimately be far wider than its + -- constructed size (RefreshStates stretches layoutCol "both" groups to the full + -- content width), so guessing squeezes those children and truncates their text. + -- Skip the sizing instead and let the next pass, with a real width, do it. That + -- matches the old behaviour, where a negative SetWidth was silently a no-op. + local innerWidth = SnapLen(self, (self:GetWidth() or 0) - (padding * 2)) + local canSize = innerWidth > 0 + + -- Will this entry be laid out on this pass? Factored out of the loop below + -- so the run look-ahead cannot drift from the loop's own visibility test: + -- a hidden row must not break a run, or toggling one row's hideOn would + -- silently change the spacing of the rows around it. + local layoutDB = DF.db[GUI.SelectedMode] + local function entryVisible(entry, index) + if self.collapsed and index > 1 then return false end + local w = entry and entry.widget + if not w then return false end + if w.hideOn and layoutDB and w.hideOn(layoutDB) then return false end + return true + end + + for i, entry in ipairs(self.groupChildren) do + local widget = entry.widget + local height = entry.height + + -- Close up a RUN of the same compact kind (see GUI.RowGapTight). The + -- reduction is taken off THIS row's slot, so it only ever affects the + -- gap to the row below -- and only when that row is the same compact + -- kind, which is what keeps the boundary between different kinds at + -- the full RowGap. + local kind = widget.rowKind + widget._rowTightened, widget._rowNextKind = false, nil + if kind and GUI.RowCompact[kind] then + for j = i + 1, #self.groupChildren do + if entryVisible(self.groupChildren[j], j) then + -- Recorded even when it does NOT match, so /df debug gapcheck can + -- say WHY a run did not close up: a row with no rowKind + -- sitting between two checkboxes breaks the run for the + -- layout while being invisible to the report (a widget + -- that draws nothing is skipped there), which would look + -- like the tightening was simply inert. + widget._rowNextKind = self.groupChildren[j].widget.rowKind or "" + if self.groupChildren[j].widget.rowKind == kind then + height = height - (GUI.RowGap - GUI.RowGapTight) + widget._rowTightened = true + end + break -- only the NEXT visible row decides + end + end + end + + -- If collapsed, only show the header (first widget) + if self.collapsed and i > 1 then + widget:Hide() + else + -- Check if widget should be visible + local shouldShow = true + if widget.hideOn then + local db = DF.db[GUI.SelectedMode] + if db and widget.hideOn(db) then + shouldShow = false + end + end + + if shouldShow then + widget:ClearAllPoints() + -- Snap y at USE, not as it accumulates: rounding each row height + -- in turn would let the error compound down a long column. + widget:SetPoint("TOPLEFT", self, "TOPLEFT", padding, SnapLen(self, y)) + -- Set width to fit within group padding (only once the group has one) + if canSize then widget:SetWidth(innerWidth) end + widget:Show() + y = y - height + visibleCount = visibleCount + 1 + else + widget:Hide() + end + end + end + + -- Show/hide collapsed summary and bottom collapse bar + if self.collapsible then + if self.collapsed then + if self.showSummary then + -- Build summary fontstring lazily on first use + if not self.collapseSummary then + self.collapseSummary = self:CreateFontString(nil, "OVERLAY") + DF:SafeSetFont(self.collapseSummary, nil, 9, "") + self.collapseSummary:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b, 0.5) + self.collapseSummary:SetJustifyH("LEFT") + self.collapseSummary:SetWordWrap(true) + end + + -- Collect labels from child widgets (skip header at index 1) + local labels = {} + for i = 2, #self.groupChildren do + local w = self.groupChildren[i].widget + -- Scan the widget's regions for a FontString with text + for _, region in ipairs({w:GetRegions()}) do + if region.GetText and region:GetText() and region:GetText() ~= "" then + labels[#labels + 1] = region:GetText() + break + end + end + end + + local summaryText = table.concat(labels, " \194\183 ") -- separated by · + self.collapseSummary:SetText(summaryText) + self.collapseSummary:ClearAllPoints() + self.collapseSummary:SetPoint("TOPLEFT", self, "TOPLEFT", padding, SnapLen(self, y)) + self.collapseSummary:SetWidth(innerWidth) + self.collapseSummary:Show() + -- Measure actual wrapped height + local summaryHeight = self.collapseSummary:GetStringHeight() or 12 + y = y - summaryHeight - 2 + else + if self.collapseSummary then self.collapseSummary:Hide() end + end + + if self.collapseBar then self.collapseBar:Hide() end + else + if self.collapseSummary then self.collapseSummary:Hide() end + if self.collapseBar then + self.collapseBar:Show() + y = y - self.collapseBar:GetHeight() + end + end + end + + -- Update group height (add padding at bottom) + -- The group's own height, snapped for the same reason its children's + -- widths are: this is what puts its TOP border on the grid. Since the + -- runtime geometry correction was removed, this IS the only thing that + -- does -- there is no after-the-fact pass to fall back on. + local totalHeight = SnapLen(self, math.abs(y) + padding) + if totalHeight < 1 then totalHeight = 1 end + self:SetHeight(totalHeight) + -- Add margin to calculated height for spacing between groups + self.calculatedHeight = totalHeight + self.margin + + return self.calculatedHeight + end + + -- Process disableOn for children + group.RefreshChildStates = function(self) + local db = DF.db[GUI.SelectedMode] + if not db then return end + + -- Group-level grey-out: set self.disableChildrenOn = function(db) ... end to + -- grey EVERY child when it returns true, EXCEPT the header and any widget + -- flagged widget.keepEnabled (the feature's own Enable toggle). Saves putting a + -- disableOn on every control; composes with per-widget disableOn (a child is + -- disabled if either says so). CreateCheckbox auto-calls RefreshStates on + -- toggle, so the grey state updates live. + local hasGroupGate = self.disableChildrenOn ~= nil + local groupOff = hasGroupGate and self.disableChildrenOn(db) or false + + for i, entry in ipairs(self.groupChildren) do + local widget = entry.widget + if widget.SetEnabled and (widget.disableOn or hasGroupGate) then + local shouldDisable = (widget.disableOn and widget.disableOn(db)) or false + if groupOff and i > 1 and not widget.keepEnabled then + shouldDisable = true + end + widget:SetEnabled(not shouldDisable) + end + if widget.refreshContent and widget:IsShown() then + widget:refreshContent(db) + end + end + end + + return group +end + +function GUI:CreateLabel(parent, text, width, color) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 380, 40) + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + -- Anchor both top corners so the wrap width tracks the frame's width. The + -- layout engine (settings-group LayoutChildren / page column sizing) resizes + -- the frame to the available width, so the text now uses the full width and + -- wraps when the window is narrow instead of overflowing/clipping at a fixed + -- width. Standalone (un-laid-out) labels keep the frame's initial `width`. + lbl:SetPoint("TOPLEFT", 0, -5) + lbl:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, -5) + lbl:SetJustifyH("LEFT") + lbl:SetWordWrap(true) + lbl:SetText(text) + + if color then + lbl:SetTextColor(color.r, color.g, color.b, color.a or 1) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 1) + end + + -- MEASURED slot height. A label is a variable-height widget, so ResolveRowHeight + -- prefers the call-site number and falls back to preferredHeight — stamping a + -- measured one here leaves every existing call site byte-identical while letting a + -- NEW one omit the number entirely and get a slot that fits the text however it + -- wraps. Hand-guessed numbers are exactly what let a 4-line blurb overlap the + -- dropdown beneath it (Colours page, Color by Time). + local function Remeasure() + local h = lbl:GetStringHeight() + if not h or h <= 0 then return false end + local newH = math.ceil(h) + (GUI.RowHeight.labelPad or 10) + if frame.preferredHeight == newH then return false end + frame.preferredHeight = newH + frame:SetHeight(newH) + return true + end + -- Force the FontString to re-flow at its CURRENT width. A dual-anchored string + -- resolves its wrap lazily, so one that was laid out before its frame reached + -- final width keeps the old single-line layout and renders ellipsised + -- ("Customize class colors used throughout DandersFra…") even though the frame + -- measures a correct 260 — /df debug guiwidth reports zero suspect frames while the + -- text is visibly truncated. Scrolling the settings window dirties it and the + -- text snaps back, which is the tell that it is a stale layout, not a bad size. + -- Clearing the text first matters: SetText with an unchanged string can early-out + -- without marking the string dirty. + local function Reflow() + local t = lbl:GetText() + if t and t ~= "" then + lbl:SetText("") + lbl:SetText(t) + end + end + -- The layout engine resizes this frame to the column's available width (see the + -- anchor note above), and GetStringHeight can return a stale single-line value until + -- the FontString has rendered at that final width — so converge ONCE on the next + -- frame, after LayoutChildren has run. Re-flow only when this label OWNS its slot: + -- inside a SettingsGroup (nothing else tracks a stored height) and with no call-site + -- number (_slotHeightExplicit, stamped by AddWidget) to override. Deliberately NOT an + -- OnSizeChanged binding — that cascade is the Aura Designer indicator-card lockup + -- documented on CreateInfoBanner; the cost is that a label added with no height does + -- not re-measure if its width changes again later. + local function Measure() + Remeasure() + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + if not frame:IsShown() then return end + -- Re-flow FIRST, and for EVERY label — the height converge below is + -- gated (it only runs for labels that own their slot), but a stale + -- wrap can strand any label, and the ones with a call-site height are + -- exactly the ones nothing else ever touches again. + Reflow() + if frame.settingsGroup and not frame._slotHeightExplicit and Remeasure() then + GUI:RelayoutHost(frame, frame.preferredHeight) + end + end) + end + end + Measure() + + -- A rebuilt page hides then re-shows its widgets, and a label re-shown at a width + -- it was not laid out at comes back with the stale single-line wrap. Re-flow on the + -- frame AFTER the show settles. Safe against the OnSizeChanged cascade that locked + -- up the AD indicator cards: Reflow re-applies the same string and never resizes, + -- so it cannot feed itself. + frame:SetScript("OnShow", function() + if C_Timer and C_Timer.After then C_Timer.After(0, Reflow) end + end) + + frame.SetText = function(self, newText) lbl:SetText(newText); Measure() end + return frame +end + +-- CreateNote: a lightweight LEVELLED note (NO box — that is CreateInfoBanner's +-- job) for an inline caveat/tip attached to a field or section. It is the middle +-- tier between a plain CreateLabel and a full banner. +-- opts.tone info | caution | danger | success — tints the note from the +-- SAME palette as the banners (via ToneHex), so notes and banners +-- speak one colour language. Omit for a neutral dim note. +-- opts.prefix optional lead word ("Note", "Warning", "Recommendation") shown +-- in the tone colour, followed by ": " and the body in dim text. +-- opts.width wrap width. +-- Returns a CreateLabel frame, so it is a drop-in anywhere a label goes. +function GUI:CreateNote(parent, text, opts) + opts = opts or {} + local str + if opts.tone and opts.prefix then + -- Route the prefix through L so "Note"/"Tip"/etc. are localizable (the + -- locale metatable returns the key unchanged when a locale lacks it). + local prefix = (L and L[opts.prefix]) or opts.prefix + str = "|c" .. self:ToneHex(opts.tone) .. prefix .. ":|r " .. text + elseif opts.tone then + str = "|c" .. self:ToneHex(opts.tone) .. text .. "|r" + else + str = text + end + return self:CreateLabel(parent, str, opts.width) +end + +-- Shared link HOVER colour: the rest colour (the theme accent — blue in party, orange in +-- raid) LIGHTENED toward white. Keeps the hue, so a hovered link brightens instead of going +-- flat white and blending into white body text. One source of truth for every link's hover +-- (SetHTML links, the page/URL link buttons, and hand-rolled note links). `c` = the link's +-- rest colour (defaults to the live theme colour); returns {r,g,b}. +function GUI:LinkHoverColor(c) + c = c or (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 1, b = 1 } + local t = 0.45 -- lift toward white; tune here to re-key every link at once + return { r = c.r + (1 - c.r) * t, g = c.g + (1 - c.g) * t, b = c.b + (1 - c.b) * t } +end + +-- ============================================================ +-- CreateInfoBanner +-- ------------------------------------------------------------ +-- A self-resizing banner with an icon, body text, and a "tone" +-- (info / caution / danger / success) that controls background, +-- border, default text colour, and default icon. ("warning" is a +-- legacy alias of "caution".) Do NOT pass fontTemplate — banners +-- share one font on purpose; only the tone should vary. +-- +-- Usage: +-- local banner = GUI:CreateInfoBanner(parent, { tone = "caution", text = "..." }) +-- Add(banner, banner.layoutHeight, "both") +-- +-- Methods on the returned frame: +-- :SetTone(name) apply a preset (see TONES below) +-- :SetText(text, optColor) plain text mode, auto-wraps + auto-resizes +-- :SetHTML(html, onLinkClick) flow-layout body with clickable link buttons +-- :SetIcon(texture, r, g, b) icon texture + optional vertex colour +-- :SetIconTexture(path) icon texture only +-- :SetIconColor(r, g, b) icon vertex colour only +-- +-- The body word-wraps automatically; banner height is recomputed via +-- OnSizeChanged so resizing the GUI (or calling SetText/SetHTML) grows +-- or shrinks the banner to fit. The host page is re-laid out so widgets +-- below the banner reposition. +-- ============================================================ +-- Each tone carries FOUR colour roles so all three consumers (banners, +-- inline ToneHex text, and tooltips) stay in sync: +-- bg / border / textColor / icon+iconColor drive the BANNER box itself. +-- accent is the vivid emphasis colour +-- used for INLINE text (ToneHex) and tooltip titles, read against the +-- dark GUI background rather than the banner's own tinted bg. It is a +-- SEPARATE role from iconColor: e.g. the danger icon is a light warm so +-- the triangle pops on the orange banner, but inline "Warning" text must +-- be a real red to out-rank a caution — deriving one from the other +-- (the original bug) made inline danger paler than caution. +local INFO_BANNER_TONES = { + info = { + bg = {0.15, 0.18, 0.28, 1}, + useThemeBorder = true, borderAlpha = 0.5, + icon = "info", + textColor = {0.85, 0.85, 0.85}, + accent = {0.6, 0.8, 1}, -- light blue + }, + -- NOTE: "warning" was merged into "caution" (they were near-duplicate golds). + -- SetTone("warning") still resolves via the alias below for safety. + caution = { + bg = {0.5, 0.45, 0.1, 0.9}, + border = {0.7, 0.6, 0.1, 1}, + icon = "warning", iconColor = {1, 0.9, 0.3}, + textColor = {1, 0.95, 0.7}, + accent = {1, 0.82, 0}, -- gold + }, + danger = { + bg = {0.6, 0.3, 0.1, 0.9}, + border = {0.8, 0.4, 0.1, 1}, + -- icon kept a light warm (not the mid-orange bg hue) so the triangle pops + icon = "warning", iconColor = {1, 0.9, 0.72}, + textColor = {1, 0.85, 0.7}, + accent = {1, 0.27, 0.27}, -- real red (destructive), NOT the pale icon warm + }, + success = { + bg = {0.1, 0.4, 0.2, 0.9}, + border = {0.2, 0.6, 0.3, 1}, + icon = "check", iconColor = {0.3, 1, 0.5}, + textColor = {0.7, 1, 0.8}, + accent = {0.4, 0.85, 0.5}, -- green + }, +} +-- Legacy alias: "warning" was merged into "caution" (near-duplicate golds). +INFO_BANNER_TONES.warning = INFO_BANNER_TONES.caution +P.INFO_BANNER_TONES = INFO_BANNER_TONES + +-- Hex accent ("ffRRGGBB", for inline |c...|r escapes) matching a banner tone, so +-- inline caveat text (e.g. a warning word in a subtitle) reads as the SAME +-- info/caution/danger/success language as the banners instead of an ad-hoc colour. +-- Uses the tone's dedicated inline `accent` (NOT the banner iconColor, which is +-- tuned to sit on the banner's own bg and would make danger paler than caution). +-- The {r, g, b} behind a tone name, for callers that set a colour directly +-- rather than embedding inline markup. Same resolution order as ToneHex, so a +-- toned title and toned inline text always match. +function GUI:GetToneColor(toneName) + local t = INFO_BANNER_TONES[toneName] or INFO_BANNER_TONES.caution + return t.accent or t.iconColor or t.textColor or {1, 1, 1} +end + +function GUI:ToneHex(toneName) + local t = INFO_BANNER_TONES[toneName] or INFO_BANNER_TONES.caution + local c = t.accent or t.iconColor or t.textColor or {1, 1, 1} + return string.format("ff%02x%02x%02x", + math.floor((c[1] or 1) * 255 + 0.5), + math.floor((c[2] or 1) * 255 + 0.5), + math.floor((c[3] or 1) * 255 + 0.5)) +end + +local INFO_BANNER_ICON_PATH = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + +-- Shared inline-markup parser: split "…|cCOLOR|HlinkData|hText|h|r…" (WoW hyperlink markup +-- plus \n line breaks) into a flat token list of { type = "word"/"link"/"newline", text, data, +-- color }. Used by the InfoBanner's SetHTML flow AND GUI:CreateLink, so both read links the +-- same way — one parser, no fork. +local function ParseHTMLSegments(s) + local segs = {} + local function addWords(chunk) + local pos = 1 + while pos <= #chunk do + local nl = chunk:find("\n", pos, true) + local line = nl and chunk:sub(pos, nl - 1) or chunk:sub(pos) + for _, w in ipairs({ strsplit(" ", line) }) do + if #w > 0 then segs[#segs + 1] = { type = "word", text = w } end + end + if nl then + segs[#segs + 1] = { type = "newline" } + pos = nl + 1 + else + break + end + end + end + local rem = s + while #rem > 0 do + local pre, color, data, lt, rest = + rem:match("^(.-)|c(%x%x%x%x%x%x%x%x)|H([^|]*)|h([^|]*)|h|r(.*)") + if pre ~= nil then + addWords(pre) + segs[#segs + 1] = { type = "link", text = lt, data = data, color = color } + rem = rest or "" + else + addWords(rem) + break + end + end + return segs +end + +-- FlowSpaceWidth — the font's OWN space advance, so a word-per-FontString flow +-- (CreateLink / InfoBanner) spaces exactly like a single wrapped FontString. A +-- fixed pixel gap reads too loose at small sizes; measuring "m m" minus "mm" +-- isolates one space advance. `sizePx` set → measure the user's settings font at +-- that px (matches a banner word, which is SetSettingsFont'd); nil → measure the +-- template's own font object (a CreateLink / template-fonted word). One reused +-- probe (no per-call FontString churn); measured fresh so a font-family change is +-- always reflected. Returns the EXACT fractional advance (no pixel rounding) so the +-- flow spaces identically to native wrapped text — see the return note below. +local function FlowSpaceWidth(tmpl, sizePx) + tmpl = tmpl or "DFFontHighlightSmall" + if not S._flowProbe then + S._flowProbe = UIParent:CreateFontString(nil, "OVERLAY") + end + if sizePx and DF.SafeSetFont then + local fontName = (DF.db and DF.db.settingsFont) or "DF Roboto SemiBold" + DF:SafeSetFont(S._flowProbe, fontName, sizePx, "") + else + S._flowProbe:SetFontObject(_G[tmpl] or _G.GameFontHighlight) + end + -- Average over N spaces: each GetStringWidth is pixel-rounded, so a single-space + -- "m m" - "mm" diff can inflate the space advance by ~1px (which read as too-loose + -- word gaps next to the native-wrapped notes). Isolating N spaces and dividing + -- shrinks that rounding error to ~1/N of a pixel, so the flow spaces like real text. + local N = 12 + S._flowProbe:SetText("m" .. string.rep(" m", N)); local wA = S._flowProbe:GetStringWidth() + S._flowProbe:SetText("m" .. string.rep("m", N)); local wB = S._flowProbe:GetStringWidth() + S._flowProbe:SetText("") + local sp = (wA - wB) / N + if not sp or sp <= 0 then sp = 3 end + -- Return the EXACT fractional advance, NOT math.floor(sp+0.5). Rounding a small + -- space (Roboto ~2.7px at 11px) UP to a whole pixel added a fixed sliver to every + -- word gap, so the flow read looser than a native wrapped FontString — which + -- positions its own spaces at sub-pixel offsets. Fractional here = same gap as + -- native. (Krathe: "look like normal text with links, no extra spacing.") + return sp +end + +-- ============================================================ +-- DISABLED OVERLAY — the "this feature is switched off" scrim. +-- +-- A dimming plate over the part of a page you cannot act on yet, carrying the +-- feature's name and a pointer at the toggle that turns it on. EnableMouse is +-- the working half: greying a control says "not now", but a page of buttons +-- that still FUNCTION while the feature is off reads as though it were on, and +-- the user builds a thing that silently does nothing. +-- +-- The caller anchors it, because the extent is a per-page judgement — cover +-- what can be acted on, not necessarily everything below the toggle. Explaining +-- content (a "how it works" box) is worth leaving readable; a user staring at +-- the scrim is exactly the one who still needs it. +-- +-- opts.label the big line, e.g. L["Aura Designer is disabled"] +-- opts.sublabel the small line (defaults to the shared "Enable the checkbox +-- above to use") +-- opts.level frame-level bump over the parent (default 50) +-- +-- Returns the frame; drive it with :SetShown(not enabled) from wherever the +-- flag changes. +-- ============================================================ +function GUI:CreateDisabledOverlay(parent, opts) + opts = opts or {} + local overlay = CreateFrame("Frame", nil, parent, "BackdropTemplate") + overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + (opts.level or 50)) + overlay:EnableMouse(true) + + local bg = overlay:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(C_BACKGROUND.r, C_BACKGROUND.g, C_BACKGROUND.b, 0.85) + + local label = overlay:CreateFontString(nil, "OVERLAY", "DFFontNormal") + label:SetPoint("CENTER", 0, 10) + label:SetText(opts.label or "") + label:SetTextColor(0.6, 0.6, 0.6, 1) + overlay.Label = label + + local sub = overlay:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + sub:SetPoint("TOP", label, "BOTTOM", 0, -4) + sub:SetText(opts.sublabel or L["Enable the checkbox above to use"]) + sub:SetTextColor(0.45, 0.45, 0.45, 1) + overlay.SubLabel = sub + + return overlay +end + +function GUI:CreateInfoBanner(parent, opts) + opts = opts or {} + + local banner = CreateFrame("Frame", nil, parent, "BackdropTemplate") + -- SetTone overwrites both colours, and opts.tone is applied at the bottom of + -- this function -- so these defaults only show on a tone-less banner, which + -- previously drew an untinted (white) box because nothing coloured it. + CreateElementBackdrop(banner) + -- Give the banner a defined initial height so child frames have valid positions + -- from the very first frame (before DoRecomputeHeight has run). + banner:SetHeight(opts.minHeight or 34) + + -- Icon: top-left anchored so it stays put when content wraps to multiple lines. + local icon = banner:CreateTexture(nil, "OVERLAY") + icon:SetPoint("TOPLEFT", 12, -10) + icon:SetSize(22, 22) + banner.icon = icon + + -- Plain-text body. Anchored top + right (no bottom) so the FontString + -- auto-grows to its natural wrapped height; the banner then resizes + -- to fit it via RecomputeHeight. SetWordWrap is on so long text wraps + -- at the width defined by the LEFT/RIGHT anchors. + local fontTemplate = opts.fontTemplate or "DFFontHighlight" + local body = banner:CreateFontString(nil, "OVERLAY", fontTemplate) + if not opts.fontTemplate then + -- Default body a touch below DFFontHighlight (12px) — 11px reads cleaner + -- in the banner while staying bigger than the old Small (10px). Icon stays 22. + GUI:SetSettingsFont(body, 11, "") + end + -- Y offset centres the first line on the icon (body 11px, icon 22px). The + -- text sits a few px below the icon's top so its centre lines up with the + -- icon's centre. + body:SetPoint("TOPLEFT", icon, "TOPRIGHT", 8, -5) + body:SetPoint("RIGHT", banner, "RIGHT", -12, 0) + body:SetJustifyH("LEFT") + body:SetJustifyV("TOP") + body:SetWordWrap(true) + body:SetNonSpaceWrap(true) + if body.SetMaxLines then body:SetMaxLines(0) end + banner.body = body + + banner.layoutHeight = (opts.minHeight or 28) + 6 + + local cachedH, recomputing = nil, false + + -- Sync the banner's measured slot height into its host group and re-flow (bubbling + -- to the page so sibling groups re-anchor). Shared with the measured label — see + -- GUI:RelayoutHost, which is this logic verbatim; the page bubble is what stops a + -- grown group's backdrop overshooting the next group's anchor when an animation + -- type is first selected in a border panel. + local function TriggerHostRelayout() + GUI:RelayoutHost(banner, banner.layoutHeight) + end + + local function MeasureContent() + if banner._isHTML then + -- For HTML mode the flow layout positions all widgets and returns + -- the total pixel height of all lines. Re-running it here keeps + -- positions fresh and gives us an accurate height in one step. + return math.max(18, banner._DoFlowLayout and banner._DoFlowLayout() or 18) + end + return math.max(18, body:GetStringHeight()) + end + + local pending = false + -- Set whenever a RecomputeHeight() request was deferred because the + -- banner was invisible. Cleared once a real recompute runs after the + -- banner becomes visible. OnShow checks this flag to decide whether to + -- trigger a fresh recompute when the widget surfaces. + local deferredWhileHidden = false + local function DoRecomputeHeight() + pending = false + if recomputing then return end + -- Skip when the banner is hidden — GetStringHeight on a hidden + -- FontString returns an unreliable value (width depends on the + -- parent's layout having run, and LayoutChildren doesn't SetWidth + -- on hidden widgets), and the resulting SetHeight + Trigger­Host­ + -- Relayout cascade costs real work proportional to the host + -- SettingsGroup's widget count. For consumers that mount banners + -- behind hideOn predicates that default to true (animation perf + -- warning at type=NONE) this used to fire one cascade per banner + -- at every GUI open — N indicator cards × ~25-widget group × + -- proxy-backed dbTable in Aura Designer = sustained lockup. + if not banner:IsVisible() then + deferredWhileHidden = true + return + end + local h = math.ceil(MeasureContent()) + -- Chrome: 13 px top (icon at -10, text nudged -3) + 9 px bottom = 22 px. + -- Snapped to whole device pixels so the banner's TOP border lands on the + -- grid. Done HERE rather than by opting into the generic size snapper, + -- because this function owns the height and is the thing the cascade + -- documented above runs through -- snapping the number before cachedH + -- sees it keeps the existing "did it actually change?" guard authoritative + -- instead of adding a second writer behind its back. + local newH = SnapLen(banner, math.max(opts.minHeight or 28, h + 22)) + if cachedH ~= newH then + cachedH = newH + recomputing = true + banner:SetHeight(newH) + banner.layoutHeight = newH + 6 + TriggerHostRelayout() + recomputing = false + end + -- Schedule one more measurement next frame: GetStringHeight can + -- return a stale single-line value the first time it's read after + -- a width change, before the FontString has finished re-rendering. + -- A second pass converges to the true wrapped height. + if not banner._secondPassDone then + banner._secondPassDone = true + if C_Timer and C_Timer.After then + C_Timer.After(0, DoRecomputeHeight) + end + end + end + + -- Defer measurement to next frame so FontString has rendered with its + -- current width — GetStringHeight can return a stale single-line value + -- if called immediately after a width change. Coalesce multiple calls + -- per frame via the `pending` flag. + local function RecomputeHeight() + banner._secondPassDone = false -- allow follow-up pass on every fresh trigger + if pending then return end + pending = true + if C_Timer and C_Timer.After then + C_Timer.After(0, DoRecomputeHeight) + else + DoRecomputeHeight() + end + end + + -- opts.staticHeight: skip ALL recompute machinery (no OnSizeChanged + -- binding, no OnShow re-measure, no DoRecomputeHeight cascade). + -- For consumers whose text never changes after construction AND who + -- can predict a sensible fixed height up front (e.g. animation perf + -- warning). Avoids the SetHeight → OnSizeChanged → TriggerHostRelayout + -- → g:LayoutChildren feedback loop that, in container layouts where + -- LayoutChildren re-fires SetWidth on every pass (Aura Designer's + -- indicator card body), drops FPS the moment the banner surfaces. + if not opts.staticHeight then + -- Only width changes affect the wrapped string height — height + -- changes (which our own SetHeight inside DoRecomputeHeight triggers) + -- don't. Filtering on width breaks part of the feedback loop, but + -- doesn't help when the host layout fires OnSizeChanged per frame + -- with same-or-different widths (some scroll-frame containers do). + local lastMeasuredWidth + banner:SetScript("OnSizeChanged", function(self, w, _) + if w == lastMeasuredWidth then return end + lastMeasuredWidth = w + RecomputeHeight() + end) + -- HookScript, not SetScript: CreateElementBackdrop already hooked OnShow, + -- to re-derive this frame's border thickness if the UI scale changed + -- while it was hidden, and a SetScript here would throw that hook away. + -- Nothing else would re-derive it -- a banner that appears late is + -- exactly the case that hook exists for. + banner:HookScript("OnShow", function() + if deferredWhileHidden then + deferredWhileHidden = false + cachedH = nil + lastMeasuredWidth = nil + RecomputeHeight() + end + end) + end + banner._RecomputeHeight = RecomputeHeight + + function banner:SetIconTexture(path) + self.icon:SetTexture(path) + end + + function banner:SetIconColor(r, g, b) + self.icon:SetVertexColor(r or 1, g or 1, b or 1) + end + + function banner:SetIcon(path, r, g, b) + self:SetIconTexture(path) + if r then self:SetIconColor(r, g, b) end + end + + function banner:SetTone(toneName) + local tone = INFO_BANNER_TONES[toneName] + if not tone then return end + self._tone = toneName + if tone.bg then self:SetBackdropColor(tone.bg[1], tone.bg[2], tone.bg[3], tone.bg[4] or 1) end + if tone.useThemeBorder then + local tc = (GUI.GetThemeColor and GUI.GetThemeColor()) or {r = 1, g = 1, b = 1} + self:SetBackdropBorderColor(tc.r, tc.g, tc.b, tone.borderAlpha or 1) + elseif tone.border then + self:SetBackdropBorderColor(tone.border[1], tone.border[2], tone.border[3], tone.border[4] or 1) + end + if tone.icon then + self:SetIconTexture(INFO_BANNER_ICON_PATH .. tone.icon) + end + if tone.iconColor then + self:SetIconColor(tone.iconColor[1], tone.iconColor[2], tone.iconColor[3]) + else + self:SetIconColor(1, 1, 1) + end + if tone.textColor then + self.body:SetTextColor(tone.textColor[1], tone.textColor[2], tone.textColor[3]) + end + end + + function banner:SetText(text, color) + text = text or "" + -- Idempotent guard (same freeze class as SetHTML): when already showing this + -- exact plain text, skip the cachedH reset + RecomputeHeight + host relayout + -- so a refreshContent-driven SetText can't loop. Colour is cheap to re-apply + -- without a recompute. (SetContent bakes its themed title into `text`, so a + -- mode switch changes the string and correctly re-renders.) + if not self._isHTML and self._plainText == text then + if color then + local r = color[1] or color.r + local g = color[2] or color.g + local b = color[3] or color.b + if r then self.body:SetTextColor(r, g, b) end + end + return + end + self._plainText = text + -- Hide any flow widgets from a previous SetHTML call. + if self._flowWidgets then + for _, w in ipairs(self._flowWidgets) do w:Hide() end + end + self._isHTML = false + self.body:Show() + self.body:SetText(text) + if color then + local r = color[1] or color.r + local g = color[2] or color.g + local b = color[3] or color.b + if r then self.body:SetTextColor(r, g, b) end + end + cachedH = nil + banner._secondPassDone = false + RecomputeHeight() + end + + -- Theme-coloured "Title: body" content with a live-updating title colour + + -- theme border (folds in the old CreateInfoCallout). Registers the banner as + -- a ThemeListener so the title/border re-colour on party/raid mode switch. + function banner:SetContent(title, body) + self._contentTitle, self._contentBody = title, body + if title and title ~= "" then + local tc = (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 1, b = 1 } + local hex = string.format("ff%02x%02x%02x", + math.floor(tc.r * 255), math.floor(tc.g * 255), math.floor(tc.b * 255)) + self:SetText("|c" .. hex .. title .. ":|r " .. (body or "")) + else + self:SetText(body or "") + end + if not self._themeRegistered then + self._themeRegistered = true + local p = self:GetParent() + if p then + p.ThemeListeners = p.ThemeListeners or {} + table.insert(p.ThemeListeners, self) + end + end + end + + function banner:UpdateTheme() + if self._tone then self:SetTone(self._tone) end + if self._contentTitle ~= nil or self._contentBody ~= nil then + self:SetContent(self._contentTitle, self._contentBody) + end + end + + -- SetHTML renders text + clickable links using real Button widgets in a + -- flow layout. This mirrors the original per-link-button approach that + -- reliably dispatches OnClick in WoW, unlike SimpleHTML whose + -- OnHyperlinkClick failed to fire consistently. + -- + -- Input text uses WoW hyperlink markup: |cCOLOR|HlinkData|hText|h|r + -- and \n for explicit line breaks. Plain text is word-split so wrapping + -- occurs at word boundaries when the banner is narrow. + + -- (Markup parsing is the file-level ParseHTMLSegments, shared with GUI:CreateLink.) + + -- Position all flow widgets left-to-right with wrapping; returns total + -- content height. Punctuation tokens attach to the preceding element + -- with no leading gap so "Foo," renders without extra space before the comma. + local FLOW_LINE_H = 14 + -- Banner words are SetSettingsFont'd to 11px unless a custom template is given; measure the + -- space advance at that same font so the flow spaces like native text (see FlowSpaceWidth). + local flowSpaceW = FlowSpaceWidth(fontTemplate, (not opts.fontTemplate) and 11 or nil) + local function DoFlowLayout() + if not banner._flowSegs then return 0 end + local availW = banner:GetWidth() - (12 + 18 + 8) - 12 + if availW < 20 then return FLOW_LINE_H end + local x, lineY = 0, -3 + for _, seg in ipairs(banner._flowSegs) do + if seg.type == "newline" then + x = 0; lineY = lineY - FLOW_LINE_H - 2 + elseif seg._widget then + local w = seg._w + -- Only a token that is ENTIRELY trailing punctuation (a lone "." or "," after a + -- link) hugs the preceding word. Connectors like & / - are whole words and keep + -- normal spacing on both sides — else "Texture & Colors" renders as "Texture& …". + local isPunct = seg.type == "word" and seg.text:match("^[%.%,%;%:%!%?%)%]%}]+$") and true or false + local gap = (x > 0 and not isPunct) and flowSpaceW or 0 + if x > 0 and (x + gap + w) > availW then + x = 0; lineY = lineY - FLOW_LINE_H - 2; gap = 0 + end + seg._widget:ClearAllPoints() + seg._widget:SetPoint("TOPLEFT", icon, "TOPRIGHT", 8 + x + gap, lineY) + x = x + gap + w + end + end + return math.abs(lineY - (-3)) + FLOW_LINE_H + end + banner._DoFlowLayout = DoFlowLayout + + function banner:SetHTML(text, onLinkClick) + text = text or "" + -- The link words are tinted with the theme colour at render time (below), + -- NOT baked into `text`, so a party/raid mode switch must re-render even + -- when the text is identical — fold the theme into the dedupe key. + local _tc = GUI.GetThemeColor and GUI.GetThemeColor() or {r = 1, g = 0.82, b = 0} + local themeKey = string.format("%.3f,%.3f,%.3f", _tc.r or 1, _tc.g or 1, _tc.b or 1) + -- Idempotent guard (FREEZE FIX): tearing down + rebuilding the flow widgets + -- resets cachedH and re-fires RecomputeHeight -> TriggerHostRelayout -> + -- host:RefreshStates. This banner's SetHTML is driven from a refreshContent + -- hook that RefreshStates calls on EVERY pass, so an unguarded rebuild loops + -- forever (game freeze — seen on the Buffs page when the Aura Designer banner + -- is shown). Skip the rebuild when neither the text nor the link-tint theme + -- changed; just keep the click handler current. + if self._isHTML and self._htmlText == text and self._htmlThemeKey == themeKey then + self._onLinkClick = onLinkClick + return + end + self._htmlThemeKey = themeKey + self._htmlText = text + self._onLinkClick = onLinkClick + self._isHTML = true + self.body:Hide() + + -- Tear down widgets from any previous call. + if self._flowWidgets then + for _, w in ipairs(self._flowWidgets) do w:Hide() end + end + self._flowWidgets = {} + + local tc = GUI.GetThemeColor and GUI.GetThemeColor() or {r = 1, g = 0.82, b = 0} + local segs = ParseHTMLSegments(self._htmlText) + self._flowSegs = segs + + for _, seg in ipairs(segs) do + if seg.type == "word" then + local fs = self:CreateFontString(nil, "OVERLAY", fontTemplate) + if not opts.fontTemplate then GUI:SetSettingsFont(fs, 11, "") end -- match the 11px plain body + fs:SetText(seg.text) + fs:SetTextColor(0.85, 0.85, 0.85) + seg._w = fs:GetStringWidth() + -- Give an explicit size matching the button height so TOPLEFT + -- anchors place both text words and link buttons on the same baseline. + fs:SetSize(seg._w, FLOW_LINE_H) + seg._widget = fs + self._flowWidgets[#self._flowWidgets + 1] = fs + elseif seg.type == "link" then + local btn = CreateFrame("Button", nil, self) + local fs = btn:CreateFontString(nil, "OVERLAY", fontTemplate) + if not opts.fontTemplate then GUI:SetSettingsFont(fs, 11, "") end -- match the 11px plain body + fs:SetAllPoints() + fs:SetJustifyH("LEFT") -- ink flush-left so the link spaces like a plain word + fs:SetText(seg.text) + fs:SetTextColor(tc.r, tc.g, tc.b) + -- Box width ceil'd (anti last-glyph clip), but the flow ADVANCE uses the + -- RAW width — else the ≤1px of empty box after every link became extra + -- gap before the next word (looser than native). seg._w drives the gap. + local rawW = fs:GetStringWidth() + btn:SetSize(math.ceil(rawW), FLOW_LINE_H) + btn:SetScript("OnEnter", function() + local h = GUI:LinkHoverColor((GUI.GetThemeColor and GUI.GetThemeColor()) or tc) + fs:SetTextColor(h.r, h.g, h.b) + end) + btn:SetScript("OnLeave", function() + local c = GUI.GetThemeColor and GUI.GetThemeColor() or tc + fs:SetTextColor(c.r, c.g, c.b) + end) + local segData = seg.data + btn:SetScript("OnClick", function() + if self._onLinkClick then + local _, pageId = strsplit(":", segData) + self._onLinkClick(pageId or segData) + end + end) + seg._widget = btn + seg._w = rawW + self._flowWidgets[#self._flowWidgets + 1] = btn + end + end + + DoFlowLayout() + cachedH = nil + banner._secondPassDone = false + RecomputeHeight() + end + + -- Apply opts at creation + if opts.tone then banner:SetTone(opts.tone) end + if opts.iconTexture then banner:SetIconTexture(opts.iconTexture) end + if opts.iconColor then banner:SetIconColor(opts.iconColor[1], opts.iconColor[2], opts.iconColor[3]) end + if opts.html then + banner:SetHTML(opts.text, opts.onLinkClick) + elseif opts.text then + banner:SetText(opts.text, opts.textColor) + end + + return banner +end + +-- ============================================================ +-- GUI:CreateLink — lean inline text + clickable links in a NOTE style (no box), FIXED layout. +-- The link-capable counterpart to CreateNote: same |cCOLOR|Hdata|hText|h|r markup as the +-- InfoBanner (shared ParseHTMLSegments), rendered as flowing dim body text with a themed, +-- hover-lightening Button per link — but WITHOUT the banner's self-resize machinery, so it is +-- safe inside the Aura Designer's reflowing indicator cards (no OnSizeChanged -> relayout loop +-- that drops FPS there). Only the link words are clickable/hovered (fixes the old note's +-- whole-frame click). Named CreateLink (not CreateLinkText) so we can grow other link forms. +-- +-- opts: +-- onLinkClick(data) called with the link's raw data string on click. +-- width wrap width; if given, flows immediately. Omit to flow once when the host +-- first sizes the frame (then it stops — no re-flow loop). +-- fontTemplate body font (default DFFontHighlightSmall — the note look). +-- lineHeight per-line height (default 14). +-- padTop/padBottom vertical breathing room baked into layoutHeight (default 2 / 8) so a note +-- isn't glued to the controls above/below it — the measured height (and thus +-- the slot a caller gives it) already includes it. More below than above so +-- the note reads as annotating the control above while clearing the next one. +-- Returns the frame; frame.layoutHeight is the measured height after flow; frame:Reflow(w) +-- re-flows at a new width if a caller ever needs it. +-- ============================================================ +function GUI:CreateLink(parent, text, opts) + opts = opts or {} + local onLinkClick = opts.onLinkClick + local fontTemplate = opts.fontTemplate or "DFFontHighlightSmall" + local LINE_H = opts.lineHeight or 14 + local PAD_TOP = opts.padTop or 2 + local PAD_BOTTOM = opts.padBottom or 8 + local frame = CreateFrame("Frame", nil, parent) + frame:SetHeight(LINE_H) + + local segs = ParseHTMLSegments(text or "") + local tc = (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 0.82, b = 0 } + + for _, seg in ipairs(segs) do + if seg.type == "word" then + local fs = frame:CreateFontString(nil, "OVERLAY", fontTemplate) + fs:SetText(seg.text) + fs:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) -- dim body, like a note + seg._w = fs:GetStringWidth() + fs:SetSize(seg._w, LINE_H) + seg._widget = fs + elseif seg.type == "link" then + local btn = CreateFrame("Button", nil, frame) + local fs = btn:CreateFontString(nil, "OVERLAY", fontTemplate) + fs:SetAllPoints() + fs:SetJustifyH("LEFT") -- ink flush-left so the link spaces like a plain word + fs:SetText(seg.text) + fs:SetTextColor(tc.r, tc.g, tc.b) + -- Box ceil'd (anti last-glyph clip); flow ADVANCE uses the RAW width so the + -- ≤1px of empty box after a link doesn't become extra word gap (see SetHTML). + local rawW = fs:GetStringWidth() + btn:SetSize(math.ceil(rawW), LINE_H) + btn:SetScript("OnEnter", function() + local h = GUI:LinkHoverColor((GUI.GetThemeColor and GUI.GetThemeColor()) or tc) + fs:SetTextColor(h.r, h.g, h.b) + end) + btn:SetScript("OnLeave", function() + local c = (GUI.GetThemeColor and GUI.GetThemeColor()) or tc + fs:SetTextColor(c.r, c.g, c.b) + end) + local segData = seg.data + btn:SetScript("OnClick", function() if onLinkClick then onLinkClick(segData) end end) + seg._w = rawW + seg._widget = btn + end + end + + -- Match native inter-word spacing: the flow gap is the font's own space advance, so a + -- word-per-FontString line reads exactly like a single wrapped FontString (a fixed pixel + -- gap looks too loose at small sizes). Words here use the raw template font (no + -- SetSettingsFont resize), so measure the template object directly. + local SPACE_W = FlowSpaceWidth(fontTemplate) + + -- Wrap the tokens left-to-right at `w`; punctuation hugs the preceding token. Sets the + -- frame height to fit. Fixed layout — never re-flows on its own, so no host feedback loop. + local function doFlow(w) + w = w or frame:GetWidth() or 0 + if w < 20 then return LINE_H end + local x, lineY = 0, -PAD_TOP -- start below the top so the first line isn't glued up + for _, seg in ipairs(segs) do + if seg.type == "newline" then + x = 0; lineY = lineY - LINE_H - 2 + elseif seg._widget then + -- Only a token that is ENTIRELY trailing punctuation (a lone "." or "," after a + -- link) hugs the preceding word. Connectors like & / - are whole words and keep + -- normal spacing on both sides — else "Texture & Colors" renders as "Texture& …". + local isPunct = seg.type == "word" and seg.text:match("^[%.%,%;%:%!%?%)%]%}]+$") and true or false + local gap = (x > 0 and not isPunct) and SPACE_W or 0 + if x > 0 and (x + gap + seg._w) > w then + x = 0; lineY = lineY - LINE_H - 2; gap = 0 + end + seg._widget:ClearAllPoints() + seg._widget:SetPoint("TOPLEFT", frame, "TOPLEFT", x + gap, lineY) + x = x + gap + seg._w + end + end + local h = math.abs(lineY) + LINE_H + PAD_BOTTOM + frame:SetHeight(h); frame.layoutHeight = h + return h + end + frame.Reflow = function(_, w) return doFlow(w) end + + if opts.width then + frame:SetWidth(opts.width) + doFlow(opts.width) + else + frame:SetScript("OnSizeChanged", function(self, w) + if w and w > 20 and not self._flowed then + self._flowed = true + self:SetScript("OnSizeChanged", nil) -- flow once; never re-flow (no loop) + doFlow(w) + end + end) + end + return frame +end + +-- GUI:FlashWidget — the "show me" pulse (revived from the pre-12.1 boss-debuffs jump): briefly +-- highlight a widget/section in the theme colour so the eye lands on it after a jump. One reused +-- overlay per target; the colour refreshes each call (party blue / raid orange). +-- opts (all opt-in / out): +-- fill (default true) — a soft theme-coloured WASH (peaks ~35% alpha, control stays legible). +-- border (default false) — a theme-coloured OUTLINE. Mix per call: a whole section reads well +-- as border-only; a single control as fill + border. +-- alpha fill peak alpha (default 0.35). borderSize outline thickness px (default 2). +-- The overlay is a backdrop frame parented to the target's parent + anchored to the target, so +-- it works whether the target is a Frame or a raw FontString (section headers). +function GUI:FlashWidget(widget, opts) + if not widget or not widget.GetParent then return end + opts = opts or {} + local doFill = opts.fill ~= false + local doBorder = opts.border and true or false + if not doFill and not doBorder then doFill = true end -- something has to show + local hl = widget._dfFlashHL + if not hl then + local host = widget:GetParent() or widget + hl = CreateFrame("Frame", nil, host, "BackdropTemplate") + hl:SetPoint("TOPLEFT", widget, "TOPLEFT", -3, 3) + hl:SetPoint("BOTTOMRIGHT", widget, "BOTTOMRIGHT", 3, -3) + local wl = (widget.GetFrameLevel and widget:GetFrameLevel()) + or (host.GetFrameLevel and host:GetFrameLevel()) or 1 + hl:SetFrameLevel(wl + 4) -- draw over the target + widget._dfFlashHL = hl + end + local c = (GUI.GetThemeColor and GUI.GetThemeColor()) or { r = 1, g = 0.82, b = 0 } + -- Re-issued per call so the pulse picks up the current theme colour. + CreateElementBackdrop(hl, { + fill = doFill, + outline = doBorder, + edgeSize = opts.borderSize or 2, + bgColor = { c.r, c.g, c.b, opts.alpha or 0.35 }, + borderColor = { c.r, c.g, c.b, 1 }, + }) + + -- Gentle alpha pulse (mirrors the live "show me" highlight): a few soft + -- fade in/out cycles then a slow fade to nothing — a calm breathe rather + -- than a hard flash. The group drives hl's frame alpha; the backdrop keeps + -- its own tint alpha, so the two multiply into a subtle pulse. + local pulse = hl._dfPulse + if not pulse then + pulse = hl:CreateAnimationGroup() + local PULSES, HALF = 4, 0.4 + for i = 1, PULSES do + local up = pulse:CreateAnimation("Alpha") + up:SetFromAlpha(0.3); up:SetToAlpha(1) + up:SetDuration(HALF); up:SetOrder(i * 2 - 1) + local down = pulse:CreateAnimation("Alpha") + down:SetFromAlpha(1); down:SetToAlpha(0.3) + down:SetDuration(HALF); down:SetOrder(i * 2) + end + local out = pulse:CreateAnimation("Alpha") + out:SetFromAlpha(0.3); out:SetToAlpha(0) + out:SetDuration(0.6); out:SetOrder(PULSES * 2 + 1) + pulse:SetScript("OnFinished", function() hl:SetAlpha(0); hl:Hide() end) + hl._dfPulse = pulse + end + pulse:Stop() + hl:SetAlpha(1) + hl:Show() + pulse:Play() +end + +-- GUI:LinkToSetting — the click action for a settings-link: jump to a setting and flash it. +-- Unifies same-page and cross-page so every link behaves identically. target: +-- page tab to switch to first (nil = stay on the current page). +-- section section header text — scrolls to it (Search:ScrollToSection) and flashes it. +-- widget explicit widget to flash (overrides the section-header lookup). +-- scrollTo optional function() that scrolls a CUSTOM container (e.g. an Aura Designer card) +-- to the target — used instead of the page section scroll. +-- flash false = no pulse; a table = FlashWidget opts (fill / border / …) so a link picks +-- its own highlight style; nil or true = the default flash. +function GUI:LinkToSetting(target) + if type(target) ~= "table" then return end + local function go() + local w = target.widget + if target.scrollTo then + target.scrollTo() + elseif target.page and target.section and DF.Search and DF.Search.ScrollToSection then + w = DF.Search:ScrollToSection(target.page, target.section) or w + end + if w and target.flash ~= false then + local fopts = type(target.flash) == "table" and target.flash or nil + C_Timer.After(0.05, function() GUI:FlashWidget(w, fopts) end) -- after the scroll settles + end + end + if target.page and GUI.SelectTab then + GUI.SelectTab(target.page) + C_Timer.After(0.12, go) -- let the tab build + lay out before scroll/flash + else + go() + end +end + +-- GUI:CreateColorsPageLink — the shared "Customize duration colors on the Colors page." +-- note (GUI:CreateLink, note style — not a banner). Its only link jumps to the shared, +-- account-wide Color-by-Time editor on the Colors page and border-flashes that whole +-- section so the eye lands on it. Used by the aura pages (buffs/debuffs/defensives) AND +-- the Aura Designer wherever a "Color by Time Remaining" control (text, or the expiry +-- Border/Tint modes) draws from those breakpoints — one cross-link, defined once. +-- `width` flows the fixed-layout note up front (see GUI:CreateLink); the caller then +-- AddWidget's it at note.layoutHeight. The |cffffffff is a parser placeholder — +-- CreateLink re-tints the link itself. +function GUI:CreateColorsPageLink(parent, width) + local link = string.format("|cffffffff|HdfColors|h%s|h|r", L["Colors page"]) + local text = string.format(L["Customize duration colors on the %s."], link) + return GUI:CreateLink(parent, text, { + width = width, + onLinkClick = function() + GUI:LinkToSetting({ + page = "display_classcolors", + section = L["Color by Time"], + flash = { border = true, fill = false }, -- whole section → outline only + }) + end, + }) +end + +-- Sibling of CreateColorsPageLink for the shared per-dispel-type palette: jumps to +-- the Colors page and flashes its "Dispel Type Colors" section. Used by the debuff +-- Border page and the Dispel Overlay page — both of which draw their dispel colours +-- from that one account-wide set. +function GUI:CreateDispelColorsPageLink(parent, width) + local link = string.format("|cffffffff|HdfColors|h%s|h|r", L["Colors page"]) + local text = string.format(L["Set the per-dispel-type colours on the %s."], link) + return GUI:CreateLink(parent, text, { + width = width, + onLinkClick = function() + GUI:LinkToSetting({ + page = "display_classcolors", + section = L["Dispel Type Colors"], + flash = { border = true, fill = false }, + }) + end, + }) +end + +-- Apply the standard button look to an existing Button frame — the single +-- source of truth for button styling, shared by GUI:CreateButton AND by +-- hand-rolled buttons that need the same look (the button analogue of +-- GUI:StyleCheckButton). opts: +-- width/height resize the button +-- text create/set a centered DFFontHighlightSmall label (btn.Text) +-- accent {r,g,b} — fixes the accent colour (e.g. ClickCasting green). +-- Omit to use the mode accent (party purple / raid orange), +-- tracking the theme. +-- primary true → a prominent CTA: a persistent accent-tinted fill + +-- accent border at rest (the hover wash just brightens it). Use +-- for the main/confirming action; normal buttons are grey at rest. +-- fadeActiveText true → on SetActive(true) dim btn.Text/btn.Icon to ~0.7 alpha +-- (back to full when inactive). For an "almost always on" status +-- toggle like Sync, where the active (synced) state is the resting +-- norm so the label can recede. Leave OFF for momentary toggles +-- (Test/Unlock) and selection toggles (chips/segmented), whose +-- active text should stay bright/white. +-- Hover respects the isTab/isActive convention used by the tab bar. Hover uses +-- SetScript (matching the original CreateButton); buttons that also need a +-- tooltip should HookScript their OnEnter so it composes with the hover. +-- ============================================================ +-- GUI TOOLTIP (settings-UI tooltips only — NOT unit-frame/aura tooltips) +-- Single source for our own widget tooltips. Call from OnEnter — use HookScript +-- on StyleButton'd widgets so it composes with the hover wash; SetScript on +-- plain frames. Pair with OnLeave -> GUI:HideTooltip(). +-- opts.title (string) white by default, or tone-coloured +-- opts.tone nil | "warning" (gold) | "danger" (red) +-- opts.anchor default: at the CURSOR, lifted clear of it (see CURSOR_LIFT). +-- Krathe's call, 2026-07-27: a settings tooltip should appear where +-- you are pointing, not pinned to a widget edge whose size you are +-- not thinking about — but sitting ON the cursor buried the control +-- you were reading about, so it is offset upward. +-- ⚠ Do NOT pass one per call site. The whole point of the default +-- living here is that every tooltip in the settings UI behaves the +-- same; a page that sets its own is the disjointedness we just +-- removed. ANCHOR_TOP in particular clamps over the owner near the +-- top of the frame — it was in use 14 times and is now gone. +-- opts.lines array; each element is one of: +-- "text" -> body grey (0.7), wrapped +-- " " -> blank spacer +-- { text = , hint = true } -> dim grey (0.55) action hint, wrapped +-- { text = , accent = true } -> mode/context accent colour, wrapped +-- { text = , color = {r,g,b} } -> explicit colour, wrapped +-- ============================================================ +-- The line grammar, shared by ShowTooltip and ShowGameTooltip so a DF line +-- appended under a spell tooltip reads exactly like one under a plain title. +local function AddTooltipLines(lines) + if not lines then return end + local acc + for _, line in ipairs(lines) do + if line == " " or line == "" then + GameTooltip:AddLine(" ") + elseif type(line) == "string" then + GameTooltip:AddLine(line, 0.7, 0.7, 0.7, true) + elseif type(line) == "table" and (line.text or line.left) then + local r, g, b = 0.7, 0.7, 0.7 + if line.hint then + r, g, b = 0.55, 0.55, 0.55 + elseif line.accent then + acc = acc or GetThemeColor() + r, g, b = acc.r, acc.g, acc.b + elseif line.color then + -- Accept {r=,g=,b=} or {r,g,b}: the palette uses the first, most + -- call sites building a colour inline reach for the second. + local c = line.color + r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] + end + if line.left then + -- Two-column form (label … value), for key/value dumps. Never + -- wraps -- AddDoubleLine has no wrap argument. + GameTooltip:AddDoubleLine(line.left, line.right, r, g, b, 1, 1, 1) + else + GameTooltip:AddLine(line.text, r, g, b, true) + end + end + end +end + +-- How far the cursor-anchored tooltip is nudged off the pointer. ONE dial. +-- +-- Small on purpose. This started at 24 while the tooltip could still appear over +-- a slider or dropdown, where it had to clear the whole control. Now that the +-- hover lives on the LABEL only (see GUI:AttachTooltip) there is nothing +-- underneath worth clearing — the lift just has to keep the tooltip off the +-- words you are reading, so a nudge does it. +-- +-- ⚠ ANCHOR_CURSOR_RIGHT, not ANCHOR_CURSOR — plain ANCHOR_CURSOR DISCARDS the +-- offsets. Verified against the client's own code rather than assumed: +-- Blizzard_AuraContainer/Blizzard_AuraButton.lua asserts the signature +-- SetOwner(point, offsetX, offsetY) with both offsets optional numbers, and +-- Blizzard's own callers only ever pass offsets alongside the _RIGHT / _LEFT +-- variants (QuestDataProvider "ANCHOR_CURSOR_RIGHT", 5, 2 — never with the plain +-- cursor anchor). Positive Y is up in WoW, so this lifts regardless of which +-- edge the anchor pins. +local CURSOR_LIFT_X, CURSOR_LIFT_Y = 0, 8 + +function GUI:ShowTooltip(owner, opts) + if not owner or not opts or not opts.title then return end + if opts.anchor then + GameTooltip:SetOwner(owner, opts.anchor) + else + GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT", CURSOR_LIFT_X, CURSOR_LIFT_Y) + end + -- Title colour is single-sourced from the tone's inline accent so a tooltip + -- title reads the same as inline ToneHex text of the same tone. Untoned = white. + local toneDef = opts.tone and INFO_BANNER_TONES[opts.tone] + local ac = toneDef and toneDef.accent + if ac then + GameTooltip:SetText(opts.title, ac[1], ac[2], ac[3]) + else + GameTooltip:SetText(opts.title, 1, 1, 1) + end + AddTooltipLines(opts.lines) + GameTooltip:Show() +end + +-- ============================================================ +-- GAME-DATA TOOLTIP (a spell / item / equipped item / aura, plus our own lines) +-- The shape ShowTooltip cannot express: the game writes the header, we append +-- underneath. Six settings-UI surfaces hand-rolled it — the whole binding editor +-- plus the spell picker — and only the picker handled the case that actually +-- bites: GameTooltip:SetSpellByID renders NOTHING when the client has not loaded +-- that spell's data yet, so a bare call leaves an empty tooltip. Everywhere else +-- silently showed nothing on a cold cache. +-- +-- opts (pick ONE source): +-- spellID a spell — gets the load-on-demand retry below +-- itemID an item by id +-- inventorySlot [+ unit] an equipped item ("player" unless unit is given) +-- unit + auraInstanceID a live aura +-- plus: +-- fallbackTitle shown when the game has no data at all, so a hover is never +-- blank (for a spell, the id is added under it) +-- isCurrent(owner, spellID) is this owner STILL showing this spell? Guards the +-- async re-render on pooled / rebindable rows. Omit for a row +-- that only ever shows one thing. +-- anchor, lines exactly as ShowTooltip +-- ============================================================ + +-- Fill from the game. Returns whether it actually produced content. +local function SeedGameTooltip(opts) + local ok + if opts.spellID then + -- pcall: SetSpellByID errors outright on ids the client considers + -- invalid (possible for stale DB entries) — treat that as "no data". + ok = pcall(GameTooltip.SetSpellByID, GameTooltip, opts.spellID) + elseif opts.itemID then + ok = pcall(GameTooltip.SetItemByID, GameTooltip, opts.itemID) + elseif opts.inventorySlot then + ok = pcall(GameTooltip.SetInventoryItem, GameTooltip, opts.unit or "player", opts.inventorySlot) + elseif opts.unit and opts.auraInstanceID then + ok = pcall(GameTooltip.SetUnitAura, GameTooltip, opts.unit, opts.auraInstanceID) + else + return false + end + return ok and GameTooltip:NumLines() > 0 +end + +function GUI:ShowGameTooltip(owner, opts) + if not owner or not opts then return end + + -- Render the whole thing: game data (or the fallback), then our lines. Used + -- for the first paint AND the re-paint after a late spell load, so the + -- appended lines survive the reload instead of vanishing with it. + local function Fill() + local seeded = SeedGameTooltip(opts) + if not seeded then + if opts.fallbackTitle and opts.fallbackTitle ~= "" then + GameTooltip:AddLine(opts.fallbackTitle, 1, 1, 1) + end + if opts.spellID then + GameTooltip:AddLine(format(L["Spell IDs: %s"], tostring(opts.spellID)), 0.5, 0.5, 0.5) + end + end + AddTooltipLines(opts.lines) + GameTooltip:Show() + return seeded + end + + -- Same cursor default as ShowTooltip — a spell tooltip on a settings row has + -- to behave like every other tooltip in the window, and this one was still on + -- the old ANCHOR_RIGHT. + if opts.anchor then + GameTooltip:SetOwner(owner, opts.anchor) + else + GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT", CURSOR_LIFT_X, CURSOR_LIFT_Y) + end + if Fill() or not opts.spellID then return end + + -- Nothing rendered. If the data exists server-side but is not loaded yet, + -- this both requests the load and re-renders when it arrives. A cached spell + -- never reaches here (SetSpellByID already had its chance), so the callback + -- cannot double-add the fallback. + local spell = Spell and Spell.CreateFromSpellID and Spell:CreateFromSpellID(opts.spellID) + if not spell or spell:IsSpellEmpty() or spell:IsSpellDataCached() then return end + local spellID, isCurrent = opts.spellID, opts.isCurrent + spell:ContinueOnSpellLoad(function() + if GameTooltip:IsShown() and GameTooltip:IsOwned(owner) and owner:IsMouseOver() + and (not isCurrent or isCurrent(owner, spellID)) then + GameTooltip:ClearLines() + Fill() + end + end) +end \ No newline at end of file diff --git a/GUI/Widgets.lua b/GUI/Widgets.lua new file mode 100644 index 00000000..e143ddef --- /dev/null +++ b/GUI/Widgets.lua @@ -0,0 +1,4518 @@ +-- Part 3 of the GUI toolkit, split from the original GUI.lua. +-- These re-declarations are aliases of the SAME objects the first part +-- created; they add no state. See docs/reorg-tools/splits.manifest. +local addonName, DF = ... +local GUI = DF.GUI +local L = DF.L +local S = GUI._state +local P = GUI._priv +local C_PANEL, C_ELEMENT, C_BORDER, C_HOVER, C_TEXT, C_TEXT_DIM = + GUI.Colors.panel, GUI.Colors.element, GUI.Colors.border, GUI.Colors.hover, GUI.Colors.text, GUI.Colors.textDim +local CloseOpenDropdown = GUI._priv.CloseOpenDropdown +local GetThemeColorFor = GUI.GetThemeColorFor +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local SnapHeightEven = GUI.SnapHeightEven +local CreateElementBackdrop = GUI.CreateElementBackdrop +local CreatePanelBackdrop = GUI.CreatePanelBackdrop +local StyleScrollBar = GUI.StyleScrollBar +local INFO_BANNER_TONES = GUI._priv.INFO_BANNER_TONES + +-- Counterpart to ShowTooltip: hide the shared GameTooltip. Wrapped so callers +-- route through GUI instead of poking GameTooltip directly. +function GUI:HideTooltip() + GameTooltip:Hide() +end + +-- ============================================================ +-- ATTACHING a tooltip to a settings widget — ONE way, for every factory. +-- +-- Three factories used to each have their own idea, and on one page the same +-- gesture did three different things: +-- checkbox .tooltip hover the container ANCHOR_CURSOR +-- dropdown .tooltip hover the BUTTON ANCHOR_CURSOR +-- slider .tooltipText hover the SLIDER ANCHOR_RIGHT +-- Worse, on a dropdown and a slider the LABEL sits above the control, outside +-- its hit rect, so hovering the words never did anything — while on a checkbox +-- (label beside the box, inside the container) it did. Five more factories — +-- colour picker, font / texture dropdown, input, growth control — had no +-- tooltip support at all, so ~136 controls could not carry one. +-- +-- The rule now: THE HIT AREA IS THE LABEL, and only the label. +-- +-- ⚠ This is deliberately NOT the whole widget. The first version of this hovered +-- the control too, which is the obvious reading of "make the label work" — but a +-- cursor-anchored tooltip then sits on top of the slider or dropdown you are +-- trying to read and operate, and no amount of offsetting it fully solves that, +-- because the thing you point at IS the thing being covered. Krathe's call, +-- 2026-07-27, after trying both. Reading and adjusting are separate gestures: +-- point at the words to find out what it does, point at the control to use it. +-- +-- The label is a FontString and cannot take mouse input, so each widget gets one +-- invisible frame sized to the label's own rect. That also handles a label wider +-- than its container for free (the checkbox case) — the frame follows the TEXT, +-- not the box, so there is no hit-rect arithmetic to keep in sync. +-- +-- The anchor is whatever ShowTooltip defaults to — set in ONE place so no page +-- can drift. Nothing here passes an anchor, deliberately. +-- +-- The spec is read AT HOVER TIME, not when it is attached — every call site +-- sets it after creation, on the container the factory returned: +-- widget.tooltip = "body" title = the widget's own label +-- widget.tooltip = { title=, lines=, tone= } the full ShowTooltip shape +-- widget.tooltipText / .tooltipSubText legacy pair, still honoured +-- ============================================================ +local function ResolveTooltipSpec(widget, label) + local t = widget.tooltip + if type(t) == "table" then + -- Full spec from the caller. Default the title to the label so the + -- common case only has to say what the setting DOES. + if t.title == nil then t.title = label end + return t + end + if type(t) == "string" and t ~= "" then + return { title = label, lines = { t } } + end + -- Legacy pair. Deliberately NOT re-titled from the label: these read as + -- title-then-subtitle by design (the Frame Level explainer, the override + -- markers), and re-titling them would change tooltips that are already + -- correct. New code should use .tooltip. + if widget.tooltipText then + return { + title = widget.tooltipText, + lines = widget.tooltipSubText and { widget.tooltipSubText } or nil, + } + end + return nil +end + +-- widget the frame the caller holds and sets .tooltip on (the container) +-- label the default title +-- labelRegion the label FontString — the hit frame is built over ITS rect +function GUI:AttachTooltip(widget, label, labelRegion) + if not labelRegion then return end + + local hit = CreateFrame("Frame", nil, widget) + -- Two-corner anchored to the FontString, so it tracks the text if the label + -- is ever re-set or re-fonted. The 2px vertical bleed makes a single line of + -- small text comfortable to hit without reaching the control below it. + hit:SetPoint("TOPLEFT", labelRegion, "TOPLEFT", 0, 2) + hit:SetPoint("BOTTOMRIGHT", labelRegion, "BOTTOMRIGHT", 0, -2) + hit:EnableMouse(true) + -- Above the widget's own children so the label area wins the mouse, but it + -- only ever covers the TEXT, so nothing clickable is behind it. + hit:SetFrameLevel((widget:GetFrameLevel() or 0) + 5) + + hit:SetScript("OnEnter", function() + local spec = ResolveTooltipSpec(widget, label) + if spec then GUI:ShowTooltip(hit, spec) end + end) + hit:SetScript("OnLeave", function() GUI:HideTooltip() end) + + widget.dfTooltipHit = hit -- exposed for a caller that needs to re-anchor it + return hit +end + +function GUI:StyleButton(btn, opts) + opts = opts or {} + if opts.width or opts.height then + btn:SetSize(opts.width or btn:GetWidth(), opts.height or btn:GetHeight()) + end + -- Land the height on an EVEN number of device pixels, whether it came from + -- opts or the caller sized the button itself. Buttons are chained with + -- centre-aligning anchors (Copy <- Sync <- Reset, Clicks <- Test <- Unlock), + -- so an odd height puts the whole row on a half pixel -- and since nothing + -- corrects a control's position at runtime, getting the size right at + -- construction is the only thing that keeps their edges crisp. + -- Construction-time and once, so it cannot drive an OnSizeChanged cascade. + local bh = btn:GetHeight() + if bh and bh > 0 then + local sh = SnapHeightEven(btn, bh) + if sh and math.abs(sh - bh) > 1e-4 then btn:SetHeight(sh) end + end + CreateElementBackdrop(btn) -- mixes in BackdropTemplate if needed + + -- Optional label + leading icon. opts.icon = { texture, size (14), + -- color {r,g,b}, gap (4) }. opts.align controls layout: + -- "center" (default) — centre the icon+label as a GROUP (text-only centres + -- the label; icon-only centres the icon). Best for compact buttons whose + -- width ~ their content. + -- "left" — pin the icon at opts.leftPad (12) with the label after it. Best + -- for wide / full-width list-style buttons, where centred content floats + -- in a sea of empty space. + local iconOpt = opts.icon + local iconGap = (iconOpt and iconOpt.gap) or 4 + local iconW = (iconOpt and (iconOpt.size or 18)) or 0 + local hasText = opts.text ~= nil and opts.text ~= "" + local align = opts.align or "center" + local leftPad = opts.leftPad or 12 + -- Toned buttons (danger / success): neutral at rest with an accent-coloured + -- label+icon — soft red for destructive, soft green for affirmative — plus the + -- accent hover wash. A coloured-text button, NOT a filled CTA (the accent set + -- below drives the hover). Mirrors each other so Delete/Save read as a pair. + local toneLabel = (opts.tone == "danger" and { 0.9, 0.45, 0.45 }) + or (opts.tone == "success" and { 0.4, 0.85, 0.5 }) or nil + + if opts.text ~= nil then + if not btn.Text then + btn.Text = btn:CreateFontString(nil, "OVERLAY", opts.font or "DFFontHighlightSmall") + -- Register it as the button's font string so the NATIVE Button:SetText + -- / GetText keep working. Without this, a caller that relabels later + -- (a Start/Stop or Pause/Resume toggle) silently no-ops and the button + -- freezes on its first label -- an easy regression when converting a + -- Blizzard-template button, which always had one. + if btn.SetFontString then btn:SetFontString(btn.Text) end + end + btn.Text:SetText(opts.text) + if toneLabel then + btn.Text:SetTextColor(toneLabel[1], toneLabel[2], toneLabel[3]) + else + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + + if iconOpt then + btn.Icon = btn.Icon or btn:CreateTexture(nil, "OVERLAY") + btn.Icon:SetTexture(iconOpt.texture) + btn.Icon:SetSize(iconW, iconW) + if iconOpt.color then + btn.Icon:SetVertexColor(iconOpt.color.r, iconOpt.color.g, iconOpt.color.b) + elseif toneLabel then + btn.Icon:SetVertexColor(toneLabel[1], toneLabel[2], toneLabel[3]) + end + end + + -- Anchor the icon/label per alignment. + if align == "left" then + if iconOpt then + btn.Icon:ClearAllPoints() + btn.Icon:SetPoint("LEFT", leftPad, 0) + end + if btn.Text then + btn.Text:ClearAllPoints() + if iconOpt then + btn.Text:SetPoint("LEFT", btn.Icon, "RIGHT", iconGap, 0) + else + btn.Text:SetPoint("LEFT", leftPad, 0) + end + end + else + if btn.Text then + btn.Text:ClearAllPoints() + -- Offset right by half the icon+gap so the icon+label GROUP centres. + btn.Text:SetPoint("CENTER", btn, "CENTER", (iconOpt and hasText) and (iconW + iconGap) / 2 or 0, 0) + end + if iconOpt then + btn.Icon:ClearAllPoints() + if hasText then + btn.Icon:SetPoint("RIGHT", btn.Text, "LEFT", -iconGap, 0) + else + btn.Icon:SetPoint("CENTER", btn, "CENTER", 0, 0) + end + end + end + + -- Hover: an accent wash via the native HIGHLIGHT layer (auto-shown on + -- mouseover, like StyleCheckButton / the menu buttons) PLUS a darker accent + -- border for definition. `primary` buttons additionally keep a persistent + -- accent-tinted fill + accent border at rest. Accent = explicit opts.accent + -- (e.g. ClickCasting green) or the mode accent (party purple / raid orange). + local accent = opts.accent + -- tone presets: a destructive "danger" button reuses ALL the accent + -- machinery (hover wash, hover border, primary fill) with a fixed FF4444 red. + -- So a plain danger button is neutral-at-rest with a red hover, and + -- danger+primary is a filled red CTA. Fixed colour ⇒ it won't theme-track + -- (correct — destructive red shouldn't follow the party/raid accent). + if not accent then + if opts.tone == "danger" then + accent = { r = 1, g = 0.27, b = 0.27 } + elseif opts.tone == "success" then + accent = { r = 0.3, g = 0.8, b = 0.45 } + end + end + local primary = opts.primary + local fadeActiveText = opts.fadeActiveText + -- Underline TAB style (opts.tab): the button is transparent (no fill/border) + -- and its active cue is a 2px accent stripe along the bottom + an accent label + -- (dim label when inactive). Driven by SetActive, like a toggle. Distinct from + -- the legacy `isTab` filled-sidebar branch in restBackdrop. + local isTabStyle = opts.tab + -- Ghost action (opts.ghost): transparent like a tab but with no underline — an + -- accent label + faint hover wash. For quiet inline actions (e.g. "+ Add"). + local ghost = opts.ghost + -- Persistent semantic accent (opts.tinted): the accent is meaningful and stays + -- ON at rest — faint accent fill + accent border + accent label — rather than + -- being a neutral button with an accent hover. For role quick-add buttons etc. + -- where the colour IS the button's identity. Pass a fixed opts.accent. + local tinted = opts.tinted + -- Neutral hover (opts.hoverTone = "neutral"): the wash is the plain C_HOVER + -- grey and the border does NOT go accent. For surfaces that are a PLACE + -- rather than an action -- a card header, a list row -- where an accent + -- hover would read as "this is a call to action". Card headers and dropdown + -- rows previously hand-rolled this as an OnEnter/OnLeave SetBackdropColor + -- swap, which duplicated the rest colours at every site. + local neutralHover = opts.hoverTone == "neutral" + -- opts.restBorderColor: let a consumer keep its OWN border identity at rest -- + -- e.g. an Aura/Text group card header tinted by that group's colour -- while + -- fill, hover, selection and disabled all stay shared. Applies ONLY to the + -- neutral rest branch; active / primary / tinted keep their accent-derived + -- borders, so the override can't fight a state the button is in. + local restBorder = opts.restBorderColor + local hl = btn:GetHighlightTexture() or btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture("Interface\\Buttons\\WHITE8x8") + hl:SetAllPoints(btn) + btn.Highlight = hl + + if isTabStyle then + local stripe = btn:CreateTexture(nil, "OVERLAY") + stripe:SetTexture("Interface\\Buttons\\WHITE8x8") + stripe:SetHeight(3) + stripe:SetPoint("BOTTOMLEFT", 0, 0) -- full-width underline (no insets) + stripe:SetPoint("BOTTOMRIGHT", 0, 0) + stripe:Hide() + btn.dfTabStripe = stripe + end + + -- The resting backdrop the button returns to on mouse-out (and that primary + -- buttons also wear permanently): accent-tinted for primary, the active-tab + -- panel colour for active tabs, otherwise the neutral element colour. + local function restBackdrop(self, a) + if isTabStyle then + -- Underline tab: a faint neutral cell when inactive (so every tab's + -- bounds stay visible and the active one doesn't appear to "grow"), + -- and a stronger accent fill when active (a held-hover highlight) + -- beneath its stripe. + if self.dfActive then + self:SetBackdropColor(a.r, a.g, a.b, 0.18) + else + self:SetBackdropColor(1, 1, 1, 0.05) + end + self:SetBackdropBorderColor(0, 0, 0, 0) + return + end + if ghost then + -- Ghost action: a faint neutral cell (matching inactive tabs) with an + -- accent label, so it sits consistently in a tab strip; the wash + -- brightens it on hover. + self:SetBackdropColor(1, 1, 1, 0.05) + self:SetBackdropBorderColor(0, 0, 0, 0) + return + end + if tinted then + -- Persistent semantic accent: faint accent fill + medium accent border + -- at rest (label/icon accent-coloured in ApplyThemeColor). Hover adds a + -- full-accent border + the wash brightens the fill. + self:SetBackdropColor(a.r * 0.15, a.g * 0.15, a.b * 0.15, 0.9) + self:SetBackdropBorderColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 0.8) + return + end + if self.dfActive then + -- Selected toggle/segmented button: a subtle accent fill + a clear + -- accent border (more than the muted hover border, but toned down from + -- full so it doesn't read as a heavy bright outline). + self:SetBackdropColor(a.r * 0.3, a.g * 0.3, a.b * 0.3, 1) + self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) + elseif primary then + -- Filled accent CTA: a medium accent fill with a slightly darker + -- accent border (the same border-darker-than-fill relationship as the + -- standard hover) so it reads like an emphasised standard button, not + -- a dark fill ringed by a harsh bright outline. + self:SetBackdropColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 1) + self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) + elseif self.isTab and self.isActive then + self:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 1) + self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + else + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + if restBorder then + self:SetBackdropBorderColor(restBorder.r or restBorder[1], + restBorder.g or restBorder[2], + restBorder.b or restBorder[3], + restBorder.a or restBorder[4] or 1) + else + self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + end + end + end + + -- The hover wash's colour, factored out so it can be re-resolved at HOVER time + -- rather than only at build time. The theme listener registered below lands on + -- the button's PARENT, and a button parented into a scroll child -- every row + -- in the Filter Designer, the spell list, the binding editor -- hangs it on a + -- frame no theme walk ever visits. Its wash then stays frozen at whatever the + -- accent was when the page was built, so a raid-mode hover drew an orange + -- border (computed live in OnEnter) over a party-blue fill. The border was + -- always right; the wash simply wasn't asking again. + local function applyWash(c) + if neutralHover then + hl:SetVertexColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.55) + else + hl:SetVertexColor(c.r, c.g, c.b, (isTabStyle or ghost) and 0.15 or 0.30) + end + end + + btn.ApplyThemeColor = function(c) + applyWash(c) + if isTabStyle then + restBackdrop(btn, c) -- keep the tab transparent (no fill/border) + -- refresh the stripe colour + the active label to the new accent + if btn.dfTabStripe then btn.dfTabStripe:SetColorTexture(c.r, c.g, c.b, 1) end + if btn.dfActive and btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end + elseif ghost or tinted then + restBackdrop(btn, c) -- faint cell / tinted fill; accent-coloured label + if btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end + if btn.Icon then btn.Icon:SetVertexColor(c.r, c.g, c.b) end -- icon matches the accent label + elseif primary or btn.dfActive then + restBackdrop(btn, c) -- refresh persistent accent + end + end + + -- Toggle/segmented selection: btn:SetActive(true) marks the button as the + -- current selection (prominent accent border via restBackdrop); false returns + -- it to its normal rest. The owning group is responsible for clearing the + -- previously-active button. Works on any StyleButton'd button. + btn.SetActive = function(self, active) + self.dfActive = active and true or false + restBackdrop(self, accent or GetThemeColor()) + if isTabStyle then + -- Underline tab: show the accent stripe + accent label when active, + -- dim label when inactive. + local a = accent or GetThemeColor() + if self.dfTabStripe then + self.dfTabStripe:SetColorTexture(a.r, a.g, a.b, 1) + self.dfTabStripe:SetShown(self.dfActive) + end + if self.Text then + if self.dfActive then + self.Text:SetTextColor(a.r, a.g, a.b) + else + self.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + end + if fadeActiveText then + -- Status toggles: the active fill+border carry the "on" emphasis, so + -- the label/icon recede slightly when active (settled) and stay bright + -- when inactive (a clearer call to action). Alpha keeps this + -- independent of whatever colour the owner sets on the text/icon. + local a = self.dfActive and 0.7 or 1 + if self.Text then self.Text:SetAlpha(a) end + if self.Icon then self.Icon:SetAlpha(a) end + end + end + + -- Disabled / "greyed out": a dim backdrop + faint border, label/icon dimmed + -- via alpha (keeps their own colour, just recedes), and the hover wash + + -- border suppressed. The button stays natively enabled so a HookScript + -- tooltip can still explain WHY it's disabled; the owner's OnClick must + -- early-out on self.dfDisabled. SetDisabled(false) restores the normal/ + -- active/primary rest. + btn.SetDisabled = function(self, disabled) + disabled = disabled and true or false + -- Idempotent: bail when the state isn't actually changing. Tab refresh + -- paths (the Aura Designer's UpdateLayoutTabState) call SetDisabled(false) + -- on the sub-tabs on EVERY rebuild. Re-running the enable-restore below on + -- an already-enabled tab left the AD Effects/Layout tabs diverging from a + -- never-disabled tab (Global) and broke their hover wash — the only code + -- that ran on them but not on Global was this call. Skipping the no-op keeps + -- an enabled tab identical to one SetDisabled never touched. + if (self.dfDisabled and true or false) == disabled then return end + self.dfDisabled = disabled + if self.dfDisabled then + self:SetBackdropColor(C_ELEMENT.r * 0.55, C_ELEMENT.g * 0.55, C_ELEMENT.b * 0.55, 0.6) + self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.25) + -- Kill the hover wash through its VERTEX alpha — the same channel + -- ApplyThemeColor uses for the wash's rest STRENGTH. The old object- + -- alpha toggle (hl:SetAlpha 0/1) mixed two alpha channels, so a + -- disable->enable cycle could restore the wash at FULL strength + -- instead of 0.30 (seen live on the Filter Designer add/rename/ + -- delete buttons when switching a preset -> a custom filter). + local wc = accent or GetThemeColor() + hl:SetVertexColor(wc.r, wc.g, wc.b, 0) + if self.Text then self.Text:SetAlpha(0.35) end + if self.Icon then self.Icon:SetAlpha(0.35) end + else + self.ApplyThemeColor(accent or GetThemeColor()) -- re-assert the wash's rest colour + alpha (0.30 / 0.15) + restBackdrop(self, accent or GetThemeColor()) + if self.Text then self.Text:SetAlpha(1) end + if self.Icon then self.Icon:SetAlpha(1) end + end + end + -- The grey loop (RefreshChildStates) greys gated children via widget:SetEnabled. + -- Native Button:SetEnabled blocks clicks but won't dim a custom-backdrop button + -- (its backdrop/Text are custom, not native button regions), so layer a SetAlpha + -- dim on top. We route through native + SetAlpha, NOT SetDisabled — SetDisabled + -- stays natively clickable (it relies on an OnClick dfDisabled early-out the + -- consumer may not have) and fights the hover wash on SetActive toggles. + local nativeSetEnabled = btn.SetEnabled + btn.SetEnabled = function(self, enabled) + nativeSetEnabled(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + end + + btn.ApplyThemeColor(accent or GetThemeColor()) + if not accent then + btn.UpdateTheme = function() btn.ApplyThemeColor(GetThemeColor()) end + local root = opts.themeRoot or btn:GetParent() + if root then + root.ThemeListeners = root.ThemeListeners or {} + table.insert(root.ThemeListeners, btn) + end + end + + -- WHAT HOVERED LOOKS LIKE — the single implementation, so the mouse and a + -- proxying owner cannot drift apart. OnEnter/OnLeave below are thin wrappers. + -- + -- ⚠ NO CURRENT CONSUMER. The Filter Designer's membership button was the one + -- caller and went with the filters merge (FilterRegistry/Options.lua says so). + -- Kept because the pattern recurs and the Lock/UnlockHighlight subtlety below + -- is not obvious enough to want rediscovered. + -- + -- Call btn:SetHovered(true/false) when something ELSE owns the hit area and + -- forwards the click: a list row whose OnClick fires this button's action. + -- The row lighting its button says "this is what clicking the row does", and + -- it separates the button from the row's highlight by HUE, which is far more + -- robust than the couple of hundredths of grey that sit between C_HOVER and + -- C_ELEMENT (the Filter Designer's spell rows are exactly that case). + -- + -- ⚠ Only wire this where the owner's click REALLY performs this button's + -- action. A control the owner does not fire must not light up with it — + -- priming a destructive button that the row will not actually trigger is + -- worse than the legibility problem it would be solving. + -- + -- The wash lives on the HIGHLIGHT layer, which the client shows only for the + -- frame under the mouse, so a proxied hover needs Lock/UnlockHighlight — it + -- cannot just Show() the texture. The real mouseover is unaffected either + -- way: locking an already-hovered button is a no-op, and unlocking one still + -- under the mouse leaves the client's own highlight up. + local function applyHoverState(self, hovered) + if not hovered then + if isTabStyle or ghost then return end + if self:IsEnabled() and not self.dfDisabled then + restBackdrop(self, accent or GetThemeColor()) + end + return + end + -- Re-resolve the wash against the CURRENT theme, exactly as the border does + -- below (see applyWash). Skipped when the caller pinned a fixed accent, and + -- while disabled -- SetDisabled parks the wash at alpha 0 and it must stay + -- parked, or a disabled button would light up under the mouse. + if not accent and not self.dfDisabled then applyWash(GetThemeColor()) end + -- tab/ghost/neutral: only the auto wash, no accent border + if isTabStyle or ghost or neutralHover then return end + if self:IsEnabled() and not self.dfDisabled then + local a = accent or GetThemeColor() + if tinted then + self:SetBackdropBorderColor(a.r, a.g, a.b, 1) -- full accent border on hover + elseif self.dfActive then + -- keep the active border on hover (the wash still brightens the + -- fill, giving the hover cue). + self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) + else + -- border darkens to a shade of the accent; the HIGHLIGHT wash + -- brightens the fill. The wash is translucent (0.3), so the + -- full-opacity border still reads DARKER than the fill. Same for + -- primary — it keeps its edge and the brightening fill is the cue. + self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) + end + end + end + + -- The proxied entry point. Lock/Unlock is HERE and not in applyHoverState so + -- a real mouseover never locks: a pooled button hidden mid-hover (a list + -- refreshing under a stationary mouse) would miss its OnLeave and come back + -- lit. An owner calling SetHovered accepts that responsibility instead and + -- must clear it when it rebinds the row. + function btn:SetHovered(hovered) + -- Lock/UnlockHighlight are Button-only, and StyleButton is applied to a + -- few plain Frames too; those still get the border half of the state. + if self.LockHighlight then + if hovered then self:LockHighlight() else self:UnlockHighlight() end + end + applyHoverState(self, hovered) + end + + btn:SetScript("OnEnter", function(self) applyHoverState(self, true) end) + btn:SetScript("OnLeave", function(self) applyHoverState(self, false) end) + return btn +end + +function GUI:CreateButton(parent, text, width, height, func, iconName) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + local opts = { width = width or 120, height = height or 22, text = text } + -- Optional leading icon by Media\Icons name (14px to suit the small buttons). + if iconName then + opts.icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, size = 14 } + end + GUI:StyleButton(btn, opts) + btn:SetScript("OnClick", function(self) + if func then func(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + return btn +end + +-- Standard close/dismiss button: a small square danger-toned button showing a +-- "×" glyph. Replaces the many hand-rolled red close buttons on dialogs/panels. +-- opts = { size (20), onClick, tooltip, tone }. +-- tone = nil → dim grey "×" at rest → white on hover (close/dismiss; default) +-- tone = "danger" → RED "×" at rest → brighter red on hover (inline destructive +-- removes: list-item / tag removes). Both keep the red hover wash. +-- A horizontal row of buttons, chained left-to-right with one gap, sized as a +-- single layout slot. Pages were building this by hand every time -- a bare +-- CreateFrame, then SetPoint("LEFT", prev, "RIGHT", 6, 0) per button, plus the +-- HookScript/ShowTooltip pair on any button that needed a tooltip. +-- +-- buttons = { { label, width, onClick, icon, tooltip, key }, ... } +-- tooltip is a ShowTooltip spec ({title, lines, tone}); a button IS its own +-- label, so it takes the whole-widget hover rather than AttachTooltip's +-- label-only hit area (AttachTooltip returns early without a label region). +-- key names the button on row.buttons for a caller that needs it later. +function GUI:CreateButtonRow(parent, buttons, opts) + opts = opts or {} + local gap, h = opts.gap or 6, opts.height or 24 + local row = CreateFrame("Frame", nil, parent) + row:SetSize(opts.width or 540, opts.rowHeight or (h + 4)) + row.buttons = {} + local prev + for i, spec in ipairs(buttons) do + local btn = GUI:CreateButton(row, spec.label, spec.width or 80, h, spec.onClick, spec.icon) + if prev then + btn:SetPoint("LEFT", prev, "RIGHT", gap, 0) + else + btn:SetPoint("LEFT", 0, 0) + end + if spec.tooltip then + btn:HookScript("OnEnter", function(self) GUI:ShowTooltip(self, spec.tooltip) end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + row.buttons[spec.key or i] = btn + prev = btn + end + return row +end + +function GUI:CreateCloseButton(parent, opts) + opts = opts or {} + local size = opts.size or 20 + -- Rest/hover glyph colours: grey→white for dismiss, red→brighter-red for inline + -- destructive removes. The StyleButton red wash + border is shared by both. + local restColor = (opts.tone == "danger") and { r = 0.9, g = 0.45, b = 0.45 } or C_TEXT_DIM + local hoverColor = (opts.tone == "danger") and { r = 1, g = 0.4, b = 0.4 } or { r = 1, g = 1, b = 1 } + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = size, height = size, + tone = "danger", + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", + size = math.max(8, math.floor(size * 0.55)), + color = restColor, + }, + }) + btn:HookScript("OnEnter", function(self) self.Icon:SetVertexColor(hoverColor.r, hoverColor.g, hoverColor.b) end) + btn:HookScript("OnLeave", function(self) self.Icon:SetVertexColor(restColor.r, restColor.g, restColor.b) end) + btn:SetScript("OnClick", function(self) + if opts.onClick then opts.onClick(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + if opts.tooltip then + btn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = opts.tooltip}) + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + return btn +end + +-- A bare clickable GLYPH: an icon with a hover cue and NO chrome. This is the +-- small affordance that lives inside a row or a card header -- reorder arrows, an +-- eye visibility toggle, a clear-search "x" -- where a button box would be +-- heavier than the thing it acts on. +-- +-- Deliberately its own helper: StyleButton always draws chrome, and +-- CreateCloseButton is specifically the chromed "x". Before this existed, ~16 +-- sites hand-rolled the same three lines (create texture, tint it dim, brighten +-- it in OnEnter and restore in OnLeave). +-- +-- NOT this: a labelled row that merely CONTAINS an icon (a collapsible section +-- header with a title + chevron, a collapse bar). There the click target is the +-- whole row, not the glyph. +-- +-- opts: +-- texture icon path tooltip / onClick +-- size both dims (16) width / height -- button box, when the hit +-- area is deliberately bigger than the art +-- iconSize art size (= size) rotation -- radians, so one arrow texture +-- can serve both directions +-- color rest tint (C_TEXT_DIM) hoverColor (white) +-- +-- Returns the button with .Icon plus: +-- :SetGlyph(texture, color) re-point the art for a state change. The colour +-- passed becomes the new REST colour, so a later OnLeave restores the +-- state rather than snapping back to the original default. +-- :SetGlyphHover(bool) suppress the hover brighten -- an "off" state should +-- not light up under the mouse. +function GUI:CreateGlyphButton(parent, opts) + opts = opts or {} + local size = opts.size or 16 + local iconSize = opts.iconSize or size + + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(opts.width or size, opts.height or size) + + local icon = btn:CreateTexture(nil, "OVERLAY") + icon:SetSize(iconSize, iconSize) + icon:SetPoint("CENTER", 0, 0) + if opts.texture then icon:SetTexture(opts.texture) end + if opts.rotation then icon:SetRotation(opts.rotation) end + btn.Icon = icon + + local function unpackColor(c, dr, dg, db) + if not c then return dr, dg, db end + return c.r or c[1], c.g or c[2], c.b or c[3] + end + local hr, hg, hb = unpackColor(opts.hoverColor, 1, 1, 1) + btn._glyphHover = true + btn._glyphRest = { unpackColor(opts.color, C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) } + icon:SetVertexColor(unpack(btn._glyphRest)) + + function btn:SetGlyph(texture, color) + if texture then self.Icon:SetTexture(texture) end + if color then self._glyphRest = { unpackColor(color) } end + self.Icon:SetVertexColor(unpack(self._glyphRest)) + end + + function btn:SetGlyphHover(enabled) + self._glyphHover = enabled and true or false + end + + btn:SetScript("OnEnter", function(self) + if self._glyphHover then self.Icon:SetVertexColor(hr, hg, hb) end + if opts.tooltip then GUI:ShowTooltip(self, { title = opts.tooltip }) end + end) + btn:SetScript("OnLeave", function(self) + self.Icon:SetVertexColor(unpack(self._glyphRest)) + if opts.tooltip then GUI:HideTooltip() end + end) + if opts.onClick then + btn:SetScript("OnClick", function(self) opts.onClick(self) end) + end + return btn +end + +-- Shared panel/dialog root backdrop: a solid dark panel with an optional 1px +-- border. Centralises the inline SetBackdrop blocks scattered across dialogs and +-- floating panels. opts = { bgAlpha (0.95), border (true), borderColor {r,g,b,a} +-- or {r,g,b,a array} }. +function GUI:CreatePanelBackdrop(frame, opts) + opts = opts or {} + local bg = opts.bgColor or C_PANEL + -- A panel's border is a full-strength 1px line, where the element default is + -- half-alpha, so the border colour is always passed explicitly rather than + -- left to CreateElementBackdrop's default. + local bc = opts.borderColor + return CreateElementBackdrop(frame, { + outline = opts.border ~= false, + bgColor = { bg.r or bg[1], bg.g or bg[2], bg.b or bg[3], + opts.bgAlpha or bg.a or 0.95 }, + borderColor = bc and { bc.r or bc[1], bc.g or bc[2], bc.b or bc[3], + bc.a or bc[4] or 1 } + or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 1 }, + }) +end + +-- Mover chrome: the translucent tinted plate a drag surface wears while the frames +-- are unlocked. This is a SEPARATE helper from CreateElementBackdrop, not a flag on +-- it, because a mover has the opposite job from settings chrome -- it is meant to +-- shout. Giving movers the neutral element look would be a bug, not consistency. +-- +-- The hue comes from the GUI theme constants (C_ACCENT party purple-blue / C_RAID +-- raid orange) instead of a hardcoded literal, so retheming moves the movers too. +-- +-- ⚠ Which POLE is the caller's choice, not GUI.SelectedMode's, because a mover +-- belongs to the thing it moves: the raid mover must stay orange even while the +-- options window happens to be showing a party page. Pass isRaid where the site +-- knows; omit it only where the mover genuinely has no mode, and it will follow +-- the selected mode. +-- +-- opts: +-- isRaid true/false pins the pole; omit to follow GUI.SelectedMode +-- color {r,g,b} or {[1],[2],[3]} -- explicit override, for a mover whose +-- colour is a user setting rather than the theme +-- fillAlpha default 0.30 borderAlpha default 0.80 +-- fill = false outline only edgeSize default 2 +-- +-- The returned frame gains :RefreshMoverTint(), which re-resolves the hue against +-- the theme as it stands now -- call it if the mode changes while a mover is shown. +function GUI:CreateMoverBackdrop(frame, opts) + opts = opts or {} + local c = opts.color + if not c then + if opts.isRaid ~= nil then c = GetThemeColorFor(opts.isRaid) + else c = GetThemeColor() end + end + local r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] + CreateElementBackdrop(frame, { + fill = opts.fill, + edgeSize = opts.edgeSize or 2, + bgColor = { r, g, b, opts.fillAlpha or 0.30 }, + borderColor = { r, g, b, opts.borderAlpha or 0.80 }, + }) + frame.RefreshMoverTint = function(self, newOpts) + return GUI:CreateMoverBackdrop(self, newOpts or opts) + end + return frame +end + +-- The element backdrop, exposed to consumer files (the stylers in this file use +-- the local directly). Nothing outside should be calling SetBackdrop itself -- +-- route it through here so the look, and the border mechanism, stay in one +-- place. See the local for the opts contract: fill, outline, bgColor, +-- borderColor, backdropEdge. +function GUI:CreateElementBackdrop(frame, opts) + return CreateElementBackdrop(frame, opts) +end + +-- ============================================================ +-- DESIGNER TEMPLATE BAR (shared by the Aura / Text Designer editors) +-- Compact row: "Template: [dropdown ▾] [New][Duplicate][Rename][Delete]". +-- NOTE: the saved keys are still auraDesignerPreset(s) / textDesignerPreset(s) -- +-- only the LABELS became "template". The keys are persisted and exported, so +-- renaming them would cost a profile migration for nothing a user can see. +-- "Preset" now means only the built-in filter sets (FilterRegistry) and the +-- export/test quick-picks. +-- Picking a template assigns it to the mode (opts.getMode()) AND retargets the +-- editor; the buttons manage the library. After any change the bar calls +-- opts.onChange() so the host page can rebuild + refresh live frames. +-- opts = { kind = "aura"|"text", getMode = fn->mode, onChange = fn }. +-- Returns the bar frame; call bar:Refresh() to resync. +-- ============================================================ + +-- The addon's ONE name prompt. This was hand-rolled twice against Blizzard's +-- StaticPopup edit box — here and in the Filter Designer — each copy carrying +-- the same `self.EditBox or self.editBox or self:GetEditBox()` fallback for a +-- field name that moves between client versions. Both now come through here and +-- get DF chrome, so there is nothing left to keep in step. +-- opts = { title, message, default, acceptLabel, maxLetters, onAccept(text) } +function GUI:PromptName(opts) + opts = opts or {} + DF:ShowPopupInput({ + title = opts.title, + message = opts.message, + text = opts.default or "", + acceptLabel = opts.acceptLabel, + maxLetters = opts.maxLetters or 40, + onAccept = function(text) + -- Trim here so every caller's uniqueness check and empty-name + -- fallback sees the same thing. + if opts.onAccept then opts.onAccept(strtrim(text or "")) end + end, + }) +end + +local function PromptPresetName(message, default, acceptLabel, callback) + GUI:PromptName({ + title = L["Template Name"], + message = message, + default = default, + acceptLabel = acceptLabel, + onAccept = callback, + }) +end + +local function ConfirmDeletePreset(kind, name, onDone) + DF:ShowPopupAlert({ + title = L["Delete Template"], + message = format(L["Delete template \"%s\"? Anything using it reverts to Default."], name), + buttons = { + { + label = L["Delete"], + onClick = function() + if DF.DeleteDesignerPreset then + DF:DeleteDesignerPreset(kind, name) + if onDone then onDone() end + end + end, + }, + { label = L["Cancel"] }, + }, + }) +end + +function GUI:CreateDesignerPresetBar(parent, opts) + opts = opts or {} + local kind = opts.kind or "aura" + local getMode = opts.getMode or function() return "party" end + local onChange = opts.onChange or function() end + + local bar = CreateFrame("Frame", nil, parent) + bar:SetHeight(24) + + local label = bar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("LEFT", 0, 0) + label:SetText(L["Template:"]) + label:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local function CurrentName() + return DF:GetModeDesignerPresetName(kind, getMode()) + end + + -- True while editing a raid auto-layout (the only context with an "inherit + -- the global preset" choice — normal party/raid modes ARE the base). + -- Mode-gated: auto-layouts are RAID-only, but the GUI can be reopened on + -- the party tab while editing (ToggleGUI re-derives SelectedMode) — the + -- PARTY preset bar must not show layout state, and its "Inherit (Global)" + -- click must never clear the RAID layout's override. + local function IsEditingLayout() + return getMode() == "raid" + and DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() + end + + -- The label to show on the dropdown button: "Inherit (Global)" when the + -- edited layout has no override, otherwise the resolved preset name. + local function CurrentLabel() + if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then + return L["Inherit (Global)"] + end + return CurrentName() + end + + -- Dropdown button + menu (rebuilt on each open so it always reflects the lib) + local ddBtn = CreateFrame("Button", nil, bar, "BackdropTemplate") + ddBtn:SetSize(150, 22) + ddBtn:SetPoint("LEFT", label, "RIGHT", 6, 0) + CreateElementBackdrop(ddBtn) + ddBtn.text = ddBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + ddBtn.text:SetPoint("LEFT", 6, 0) + ddBtn.text:SetPoint("RIGHT", -16, 0) + ddBtn.text:SetJustifyH("LEFT") + ddBtn.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local arrow = ddBtn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -4, 0) + arrow:SetSize(10, 10) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local menu = CreateFrame("Frame", nil, ddBtn, "BackdropTemplate") + menu:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menu) + menu:SetPoint("TOPLEFT", ddBtn, "BOTTOMLEFT", 0, -1) + menu:SetWidth(150) + CreatePanelBackdrop(menu) + menu:Hide() + + -- Row pool: frames can't be garbage-collected in WoW, so recreating the + -- items on every open (the old Hide+SetParent(nil) approach) leaked a row + -- set per click. Reuse instead. + local menuRows = {} + local function BuildMenu() + for _, row in ipairs(menuRows) do row:Hide() end + local used = 0 + local y = -4 + local function AddItem(label, onClick) + used = used + 1 + local item = menuRows[used] + if not item then + item = CreateFrame("Button", nil, menu) + item:SetHeight(20) + item.text = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + item.text:SetPoint("LEFT", 4, 0) + item:SetScript("OnEnter", function(s) s.text:SetTextColor(1, 1, 1) end) + item:SetScript("OnLeave", function(s) s.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end) + item:SetScript("OnClick", function(s) + s.onClick() + menu:Hide() + bar:Refresh() + onChange() + end) + menuRows[used] = item + end + item:ClearAllPoints() + item:SetPoint("TOPLEFT", 4, y) + item:SetPoint("TOPRIGHT", -4, y) + item.text:SetText(label) + item.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + item.onClick = onClick + item:Show() + y = y - 20 + end + -- "Inherit (Global)" — only while editing a raid auto-layout. Clears the + -- layout's preset override so it follows your global preset. + if IsEditingLayout() then + AddItem(L["Inherit (Global)"], function() + if DF.InheritLayoutDesignerPreset then DF:InheritLayoutDesignerPreset(kind) end + end) + end + for _, name in ipairs(DF:ListDesignerPresets(kind)) do + AddItem(name, function() DF:SetModeDesignerPreset(kind, getMode(), name) end) + end + menu:SetHeight(-y + 4) + end + ddBtn:SetScript("OnClick", function() + if menu:IsShown() then menu:Hide() else BuildMenu(); menu:Show() end + end) + + -- SHARING MARKER. A template can be pointed at by the other mode, a pinned + -- set or an auto layout, and editing it then changes every one of them — + -- which nothing on this bar used to say. The dropdown is a fixed 150px, so + -- the FACT rides as a glyph and the NAMES go in the tooltip, which is free. + -- + -- Deliberately NOT clickable. Splitting a shared template off for this mode + -- is exactly what Duplicate does, two buttons to the right, and Duplicate + -- also lets you name the copy. + local shareIcon = ddBtn:CreateTexture(nil, "OVERLAY") + shareIcon:SetSize(12, 12) + shareIcon:SetPoint("RIGHT", arrow, "LEFT", -3, 0) + shareIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync") + shareIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + shareIcon:Hide() + + -- Read off the REFS, not the party/raid sync flag: sharing by hand (picking + -- the other mode's preset from this dropdown) counts exactly the same, and a + -- ref can't fall out of step with itself. + local function SharedWith() + if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then + return {} -- inheriting: this bar isn't sitting on a preset of its own + end + return (DF.ListDesignerPresetUsers and DF:ListDesignerPresetUsers(kind, CurrentName(), getMode())) or {} + end + + -- The consumers (Party/Raid, Auto Layouts, Pinned Frames) are spread across + -- three other pages, so naming them is the one thing this tooltip has to do + -- — the bar itself already shows what a template is. "can" holds for all + -- four: Auto Layouts and Pinned Frames may inherit their mode's instead + -- (a nil ref), and Party/Raid resolve to a default when nothing is set. + -- Names match their page titles so they are findable. + ddBtn:SetScript("OnEnter", function(self) + local lines = { + L["Templates can be used by Party, Raid, Auto Layouts and Pinned Frames."], + } + local shared = SharedWith() + if #shared > 0 then + lines[#lines + 1] = " " + lines[#lines + 1] = { text = format(L["Also used by: %s"], table.concat(shared, ", ")), accent = true } + -- "there" points back at the list above, so the consequence needs no + -- nouns of its own. It has to be said: a shared template's edits + -- reach a screen you are not looking at, and naming the users is + -- only the fact, not the warning. + lines[#lines + 1] = L["Edits apply there too."] + end + GUI:ShowTooltip(self, { title = L["Templates"], lines = lines }) + end) + ddBtn:SetScript("OnLeave", function() GUI:HideTooltip() end) + + -- When editing a raid auto-layout, default the NEW preset name to the + -- layout's name (e.g. editing "31-40" → prefill "31-40") so making a + -- per-layout preset is one click + Enter. nil (blank) otherwise. (Duplicate + -- names after its source preset, not the layout.) + local function EditingLayoutName() + if not IsEditingLayout() then return nil end -- mode-gated (raid only) + local apu = DF.AutoProfilesUI + if apu and apu.editingProfile then + return apu.editingProfile.name + end + return nil + end + + -- Action buttons. opts.iconButtons = true swaps the labeled buttons for + -- compact tooltipped icon-only buttons (22x22) — used where the bar shares + -- a row with other controls (Aura Designer header). Default stays labeled + -- (Text Designer) so existing callers are untouched. + local function CreateAction(labelText, iconName, width, onClick) + if opts.iconButtons then + local b = CreateFrame("Button", nil, bar, "BackdropTemplate") + GUI:StyleButton(b, { + width = 22, height = 22, + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, + size = 14, color = C_TEXT, + }, + }) + b:SetScript("OnClick", function(self) + onClick(self) + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + b:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = labelText}) + end) + b:HookScript("OnLeave", function() GUI:HideTooltip() end) + return b + end + return GUI:CreateButton(bar, labelText, width, 22, onClick) + end + + local newBtn = CreateAction(L["New"], "add", 48, function() + PromptPresetName(L["Name the new template:"], EditingLayoutName() or "", L["Create"], function(text) + local n = DF:CreateDesignerPreset(kind, text) + if n then + DF:SetModeDesignerPreset(kind, getMode(), n) + bar:Refresh(); onChange() + end + end) + end) + newBtn:SetPoint("LEFT", ddBtn, "RIGHT", 6, 0) + + local dupBtn = CreateAction(L["Duplicate"], "content_copy", 72, function() + local cur = CurrentName() + -- Duplicate defaults to " copy" (New uses the layout name, but a + -- duplicate is of a specific preset, so name it after the source). + PromptPresetName(L["Name the duplicated template:"], cur .. " copy", L["Duplicate"], function(text) + local n = DF:DuplicateDesignerPreset(kind, cur, text) + if n then + DF:SetModeDesignerPreset(kind, getMode(), n) + bar:Refresh(); onChange() + end + end) + end) + dupBtn:SetPoint("LEFT", newBtn, "RIGHT", 4, 0) + + local renameBtn = CreateAction(L["Rename"], "edit", 62, function() + local cur = CurrentName() + if cur == DF.DEFAULT_PRESET then return end + PromptPresetName(L["Rename template:"], cur, L["Rename"], function(text) + DF:RenameDesignerPreset(kind, cur, text) + bar:Refresh(); onChange() + end) + end) + renameBtn:SetPoint("LEFT", dupBtn, "RIGHT", 4, 0) + + local delBtn = CreateAction(L["Delete"], "delete", 56, function() + local cur = CurrentName() + if cur == DF.DEFAULT_PRESET then return end + ConfirmDeletePreset(kind, cur, function() bar:Refresh(); onChange() end) + end) + delBtn:SetPoint("LEFT", renameBtn, "RIGHT", 4, 0) + + local function SetActionEnabled(btn, on) + if on then + btn:Enable() + if btn.Text then btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end + if btn.Icon then btn.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end + else + btn:Disable() + -- greyed: Default can't be renamed/deleted + if btn.Text then btn.Text:SetTextColor(0.4, 0.4, 0.4) end + if btn.Icon then btn.Icon:SetVertexColor(0.4, 0.4, 0.4) end + end + end + + function bar:Refresh() + ddBtn.text:SetText(CurrentLabel()) + -- Make room for the share glyph only while it's up, so an unshared + -- preset keeps the full label width. + local isShared = #SharedWith() > 0 + shareIcon:SetShown(isShared) + ddBtn.text:ClearAllPoints() + ddBtn.text:SetPoint("LEFT", 6, 0) + ddBtn.text:SetPoint("RIGHT", isShared and -30 or -16, 0) + -- Rename/Delete act on the resolved preset; disable for the non-editable + -- Default and while a layout is inheriting (you're following the global, + -- not sitting on a layout-specific preset). + local inheriting = IsEditingLayout() and DF.IsLayoutDesignerInheriting + and DF:IsLayoutDesignerInheriting(kind) + local canModify = (CurrentName() ~= DF.DEFAULT_PRESET) and not inheriting + SetActionEnabled(renameBtn, canModify) + SetActionEnabled(delBtn, canModify) + end + + bar:Refresh() + -- The sharing glyph reflects OTHER refs (the other mode, a pinned set, an + -- auto layout), which can change without changing THIS mode's — and both + -- designer pages early-return their rebuild when their own preset is + -- unchanged, so the glyph would sit stale. Register the live bar so the + -- shared page refresh can reach it. One slot per kind: a rebuilt page + -- overwrites its own entry, and a torn-down bar is hidden, so nothing + -- accumulates. + GUI._designerPresetBars = GUI._designerPresetBars or {} + GUI._designerPresetBars[kind] = bar + return bar +end + +function GUI:RefreshDesignerPresetBars() + for _, bar in pairs(GUI._designerPresetBars or {}) do + if bar.Refresh and bar:IsShown() then bar:Refresh() end + end +end + +-- Creates a button with an icon and text +-- iconName is the name of the icon file (without path/extension) +-- iconSize is optional (defaults to 16) +-- align: "center" (default) or "left". Pass "left" for wide / full-width +-- list-style buttons where centred content floats (see GUI:StyleButton). +function GUI:CreateIconButton(parent, iconName, text, width, height, func, iconSize, align) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = width or 120, height = height or 22, + text = text, + align = align, + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, + size = iconSize or 18, + color = C_TEXT, + }, + }) + + btn:SetScript("OnClick", function(self) + if func then func(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + return btn +end + +-- Creates a \"See Also:\" section with clickable links to related pages +-- links = { {pageId = \"display_tooltips\", label = \"Tooltips\"}, ... } +function GUI:CreateSeeAlso(parent, links) + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + container:SetHeight(32) + -- The page's own footer: pinned to the bottom of the viewport on a SHORT + -- page instead of floating wherever the content happened to end. See the + -- footer block in the page layout. + container.isPageFooter = true + CreateElementBackdrop(container, { + bgColor = { 0.1, 0.1, 0.1, 0.5 }, + borderColor = { 0.3, 0.3, 0.3, 0.8 }, + }) + + local label = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("TOPLEFT", 8, -10) + label:SetText(L["See Also:"]) + label:SetTextColor(0.7, 0.7, 0.7) + + local linkButtons = {} + local separators = {} + + for i, linkData in ipairs(links) do + local link = CreateFrame("Button", nil, container) + link:SetHeight(16) + + local linkText = link:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + linkText:SetPoint("TOPLEFT", 0, -1) + linkText:SetText(linkData.label) + local c = GetThemeColor() + linkText:SetTextColor(c.r, c.g, c.b) + link.text = linkText + link.textWidth = linkText:GetStringWidth() + 4 + link:SetWidth(link.textWidth) + + link:SetScript("OnEnter", function(self) + local h = GUI:LinkHoverColor(c) + linkText:SetTextColor(h.r, h.g, h.b) + end) + link:SetScript("OnLeave", function(self) + linkText:SetTextColor(c.r, c.g, c.b) + end) + link:SetScript("OnClick", function() + if GUI.SelectTab then + GUI.SelectTab(linkData.pageId) + end + end) + + table.insert(linkButtons, link) + + -- Create separator (hidden by default, shown as needed) + if i < #links then + local sep = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + sep:SetText("•") + sep:SetTextColor(0.5, 0.5, 0.5) + table.insert(separators, sep) + end + end + + -- Layout function that handles wrapping + local function LayoutLinks() + local containerWidth = container:GetWidth() + if containerWidth < 50 then return end -- Not sized yet + + local labelWidth = label:GetStringWidth() + 16 + local firstLinkX = labelWidth -- Where first link starts + local xOffset = labelWidth + local yOffset = -9 + local lineHeight = 18 + local maxX = containerWidth - 10 + local rowCount = 1 + + -- First pass: determine which links are on which row + local linkRows = {} + local tempX = labelWidth + local currentRow = 1 + + for i, link in ipairs(linkButtons) do + local linkWidth = link.textWidth + local sepWidth = (i < #linkButtons) and 14 or 0 + + -- Check if we need to wrap + if tempX + linkWidth > maxX and tempX > labelWidth then + currentRow = currentRow + 1 + tempX = firstLinkX + end + + linkRows[i] = currentRow + tempX = tempX + linkWidth + sepWidth + end + + rowCount = currentRow + + -- Second pass: position elements + xOffset = labelWidth + local lastRowForLink = 1 + + for i, link in ipairs(linkButtons) do + local linkWidth = link.textWidth + + -- Check if we need to wrap to new line + if linkRows[i] > lastRowForLink then + xOffset = firstLinkX + yOffset = yOffset - lineHeight + lastRowForLink = linkRows[i] + end + + link:ClearAllPoints() + link:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset) + + xOffset = xOffset + linkWidth + 2 + + -- Position separator only if next link is on same row + if separators[i] then + if linkRows[i + 1] == linkRows[i] then + separators[i]:ClearAllPoints() + separators[i]:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset - 1) + separators[i]:Show() + xOffset = xOffset + 12 + else + separators[i]:Hide() + end + end + end + + -- Adjust container height based on rows. + -- + -- Snapped HERE, where the number is computed, because this widget + -- MEASURES ITSELF: LayoutLinks runs from OnSizeChanged and from a + -- C_Timer.After(0), i.e. a frame AFTER the page layout has run. + -- Correcting the height after the fact cannot win -- whatever sets a + -- grid-aligned height, this function overwrites it on the next frame, + -- and the bar's bottom edge ends up split across two device rows. + -- Measured: 28 units at 1.40625 px/unit is 39.375px, 0.375 off. + local newHeight = SnapLen(container, 10 + (rowCount * lineHeight)) + container:SetHeight(newHeight) + container.layoutHeight = newHeight + 5 + end + + container:SetScript("OnSizeChanged", LayoutLinks) + + -- Initial layout after a frame (to let width be set) + C_Timer.After(0, LayoutLinks) + + return container +end + +-- ========================================================================= +-- OVERRIDE INDICATORS FOR AUTO PROFILES +-- ========================================================================= +-- Helper function to add override indicators (star, reset button, global value text) +-- to widget containers when editing an auto profile + +-- Debug flag - when true, shows all reset buttons regardless of override state +S.overrideDebugMode = false + +-- Track all widgets with override indicators for refresh +local overrideWidgets = {} + +-- Function to check if debug mode is active (exposed for other files) +local function IsOverrideDebugMode() + return S.overrideDebugMode +end +GUI.IsOverrideDebugMode = IsOverrideDebugMode + +-- Function to refresh all override indicators +local function RefreshAllOverrideIndicators() + for _, widget in ipairs(overrideWidgets) do + if widget and widget.UpdateOverrideIndicators then + widget:UpdateOverrideIndicators() + end + end + -- Also refresh position override indicator + if GUI.UpdatePositionOverrideIndicator then + GUI.UpdatePositionOverrideIndicator() + end + -- Refresh tab override stars (auto-profiles) + if DF.AutoProfilesUI and DF.AutoProfilesUI.RefreshTabOverrideStars then + DF.AutoProfilesUI:RefreshTabOverrideStars() + end +end +GUI.RefreshAllOverrideIndicators = RefreshAllOverrideIndicators + +-- Allow other files to register widgets with override indicators +function GUI.RegisterOverrideWidget(widget) + table.insert(overrideWidgets, widget) +end + +-- NOT a dump, despite what the old description ("Auto layout override table +-- dump") claimed — it prints no table. It forces every reset button / override +-- marker visible regardless of override state, so you can see which controls +-- carry the machinery at all. S.overrideDebugMode is live: read by +-- GUI.IsOverrideDebugMode and three marker call sites. +DF:RegisterDebugSlash("DFOVERRIDEDEBUG", "Force-show every override marker and reset button", true, "/dfoverridedebug") +SlashCmdList["DFOVERRIDEDEBUG"] = function() + S.overrideDebugMode = not S.overrideDebugMode + DF:Say("Override debug mode " .. (S.overrideDebugMode and "ENABLED" or "DISABLED")) + -- Refresh all override indicators + RefreshAllOverrideIndicators() + -- Also update position panel if open + if DF.positionPanel and DF.positionPanel.UpdatePositionOverride then + DF.positionPanel.UpdatePositionOverride() + end +end + +-- ============================================================ +-- SHARED OVERRIDE CONTROLS +-- One "override active" marker (a coloured dot) + one "reset to global" button +-- (red, icon-only, danger tone — reads like the Reset Page button), so every +-- override control across the addon speaks one visual language. Callers create +-- them, position the returned frames, and toggle Show/Hide. +-- ============================================================ +-- Single source of truth for the override-marker colour. +GUI.OVERRIDE_MARKER_COLOR = { 1, 0.8, 0.2 } + +-- A coloured dot marking "this setting is overridden". Returns a hidden Button +-- so the caller can set .tooltipText / .tooltipSubText for a hover tooltip. +-- `size` = dot diameter in px (default 12); the hit frame is a touch larger. +function GUI:CreateOverrideMarker(parent, size) + size = size or 12 + local c = GUI.OVERRIDE_MARKER_COLOR + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(size + 6, size + 6) + -- Only ever a hover-tooltip target; let clicks fall through so a marker + -- placed on a clickable parent (e.g. a nav tab) doesn't eat its clicks. + -- SetPropagateMouseClicks is PROTECTED on 12.1 (ADDON_ACTION_BLOCKED if called + -- under combat lockdown — e.g. opening/refreshing settings in combat); skip it + -- there. Only matters when the marker sits on a clickable parent, and combat + -- GUI edits are the rare case. + if btn.SetPropagateMouseClicks and not InCombatLockdown() then btn:SetPropagateMouseClicks(true) end + local icon = btn:CreateTexture(nil, "OVERLAY") + icon:SetPoint("CENTER") + icon:SetSize(size, size) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\dot") + icon:SetVertexColor(c[1], c[2], c[3]) + btn.icon = icon + btn:SetScript("OnEnter", function(s) + if s.tooltipText then + GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipSubText and { s.tooltipSubText } or nil }) + end + end) + btn:SetScript("OnLeave", function() GUI:HideTooltip() end) + btn:Hide() + return btn +end + +-- A bare checkbox sized for a LIST ROW — no label, no db binding, no settings-row +-- geometry. GUI:CreateCheckbox is a whole 30px settings row with its own label and +-- hit rect, which is the wrong shape entirely inside a 22px pooled list row that +-- already owns its own text, count and selection accent. +-- +-- The caller drives it: :SetChecked(bool) to paint, opts.onClick to react. It does +-- NOT read or write the db itself, because a list row's meaning changes per bind +-- (a pooled row is a different filter every refresh) and a captured dbKey would go +-- stale the moment the pool rebinds. +-- +-- ⚠ Clicks deliberately do NOT propagate. The override marker above lets them fall +-- through because it is a passive marker; this is a control, and on a clickable row +-- the two gestures must stay separate — tick the box to switch the filter on, click +-- anywhere else to select it. Nothing here calls SetPropagateMouseClicks, which is +-- PROTECTED on 12.1 anyway (see the note in CreateOverrideMarker). +-- +-- ⚠ The BOX ITSELF is GUI:StyleCheckButton, the addon's one checkbox look — do not +-- hand-roll it again. This was hand-rolled once and drifted four ways: it drew the +-- Media\Icons\check GLYPH where every other checkbox in the addon draws a filled +-- WHITE8x8 square (a different SYMBOL, not a different style), it skipped PixelUtil +-- so it alone was unsnapped, it had no hover wash, and it recoloured its BORDER when +-- checked, which nothing else does. CreateDebugCategoryRow is the precedent for this +-- exact case — a list row with a checkbox — at the same size. +-- +-- manualCheck because this is a plain Button, not a CheckButton: SetChecked below +-- drives the mark. A real CheckButton would draw its checked mark through the native +-- checked state, which has no disabled-checked texture here — so a greyed-but-ticked +-- row (every filter row while All Buffs is on) would lose its tick entirely. +-- +-- opts: { size = 16, checkSize = 9, onClick = function(checked) end, +-- tooltip = title, tooltipDesc = line } +function GUI:CreateRowToggle(parent, opts) + opts = opts or {} + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleCheckButton(btn, { + size = opts.size or 16, + checkSize = opts.checkSize or 9, + manualCheck = true, + }) + + btn.checked = false + function btn:SetChecked(on) + self.checked = on and true or false + -- Re-tint on every paint. StyleCheckButton registers its theme listener on + -- this button's PARENT, which for a pooled list row is a frame inside a + -- scroll child that the page's theme walk never visits (same trap as + -- StyleButton's wash). The list rebinds every row on refresh, and a refresh + -- is what a mode switch produces, so painting the accent here is what + -- actually carries party purple -> raid orange. + self.ApplyThemeColor(GetThemeColor()) + self.Check:SetShown(self.checked) + end + + btn:SetScript("OnClick", function(s) + if s.onClick then s.onClick(not s.checked) end + end) + btn:SetScript("OnEnter", function(s) + if s.tooltipText then + GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipDesc and { s.tooltipDesc } or nil }) + end + end) + btn:SetScript("OnLeave", function() GUI:HideTooltip() end) + + btn.onClick = opts.onClick + btn.tooltipText = opts.tooltip + btn.tooltipDesc = opts.tooltipDesc + btn:SetChecked(false) + return btn +end + +-- A "reset to global" button — red, icon-only, danger tone (matches the Reset +-- Page button). Returns a hidden Button. opts: { size = 18, tooltip = title, +-- tooltipDesc = line, onClick = fn }. +function GUI:CreateOverrideResetButton(parent, opts) + opts = opts or {} + local size = opts.size or 18 + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = size, height = size, + tone = "danger", + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = size - 6 }, + }) + btn:Hide() + -- StyleButton owns OnEnter (hover wash); hook the tooltip on top. + btn:HookScript("OnEnter", function(self) + if opts.tooltip then + GUI:ShowTooltip(self, { title = opts.tooltip, lines = opts.tooltipDesc and { opts.tooltipDesc } or nil }) + end + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + if opts.onClick then btn:SetScript("OnClick", opts.onClick) end + return btn +end + +local function AddOverrideIndicators(container, lbl, dbKey, onReset, verticalOffset, optionsMap, dbTable) + -- Skip for proxy tables (e.g. Aura Designer) that don't support per-key override tracking + if dbTable and rawget(dbTable, "_skipOverrideIndicators") then return end + verticalOffset = verticalOffset or 0 + container.overrideOptionsMap = optionsMap + + -- Reset button (red, icon-only) at top-right; the override marker (dot) + -- sits to its left. Both are shared helpers (GUI:CreateOverride*). + local resetBtn = GUI:CreateOverrideResetButton(container, { + tooltip = L["Reset to Global"], + tooltipDesc = L["Reset this setting to its global value."], + onClick = function() if onReset then onReset() end end, + }) + resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, verticalOffset) + container.overrideResetBtn = resetBtn + + local starBtn = GUI:CreateOverrideMarker(container) + starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) + container.overrideStar = starBtn + + -- Global value text (shown when in edit mode) - positioned inline after label + local globalText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + globalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + globalText:SetTextColor(0.4, 0.4, 0.4) + globalText:Hide() + container.overrideGlobalText = globalText + + -- Checkmark icon for matching global value + local checkIcon = container:CreateTexture(nil, "OVERLAY") + checkIcon:SetSize(8, 8) + checkIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\check") + checkIcon:SetVertexColor(0.3, 0.7, 0.3) + checkIcon:Hide() + container.overrideCheckIcon = checkIcon + + -- Store dbKey for reference + container.overrideDbKey = dbKey + + -- Function to update override indicators + container.UpdateOverrideIndicators = function(self, currentValue) + -- Debug mode shows all buttons + if S.overrideDebugMode then + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideGlobalText:SetText("(debug)") + self.overrideGlobalText:SetTextColor(1, 0.8, 0.2) -- Yellow for visibility + self.overrideGlobalText:Show() + self.overrideCheckIcon:Hide() + return + end + + -- Only show when in raid mode + local GUI = DF.GUI + if not GUI or GUI.SelectedMode ~= "raid" then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide() + self.overrideCheckIcon:Hide() + return + end + + local AutoProfilesUI = DF.AutoProfilesUI + local isEditing = AutoProfilesUI and AutoProfilesUI:IsEditing() + local isRuntimeOverridden = AutoProfilesUI and AutoProfilesUI:IsOverriddenByRuntime(dbKey) + + -- Hide everything if not editing AND not runtime-overridden + if not isEditing and not isRuntimeOverridden then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide() + self.overrideCheckIcon:Hide() + return + end + + -- Runtime override mode: show star + global value, but no reset button + if isRuntimeOverridden and not isEditing then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting is being overridden by the active auto layout profile. To change it, edit the profile in the Auto Layouts tab."] + self.overrideStar:Show() + self.overrideResetBtn:Hide() -- Can't reset runtime overrides from controls + self.overrideCheckIcon:Hide() + + local globalValue = AutoProfilesUI:GetRuntimeGlobalValue(dbKey) + + -- Format global value for display + local globalDisplay + if type(globalValue) == "boolean" then + globalDisplay = globalValue and L["Yes"] or L["No"] + elseif type(globalValue) == "number" then + if globalValue == math.floor(globalValue) then + globalDisplay = tostring(globalValue) + else + globalDisplay = string.format("%.2f", globalValue) + end + elseif type(globalValue) == "table" then + if globalValue.r then + globalDisplay = L["Color"] + else + globalDisplay = "..." + end + elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then + local mapped = self.overrideOptionsMap[globalValue] + if type(mapped) == "table" then + globalDisplay = mapped.text or mapped.label or globalValue + else + globalDisplay = tostring(mapped) + end + else + globalDisplay = tostring(globalValue or L["None"]) + end + + self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) + self.overrideGlobalText:ClearAllPoints() + self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) + self.overrideGlobalText:Show() + return + end + + -- Editing mode: existing behavior + -- Check if setting is overridden + local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) + local globalValue = AutoProfilesUI:GetGlobalValue(dbKey) + + -- Show/hide star and reset button + if isOverridden then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] + self.overrideStar:Show() + self.overrideResetBtn:Show() + else + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + end + + -- Format global value for display + local globalDisplay + if type(globalValue) == "boolean" then + globalDisplay = globalValue and L["Yes"] or L["No"] + elseif type(globalValue) == "number" then + if globalValue == math.floor(globalValue) then + globalDisplay = tostring(globalValue) + else + globalDisplay = string.format("%.2f", globalValue) + end + elseif type(globalValue) == "table" then + -- Color table + if globalValue.r then + globalDisplay = L["Color"] + else + globalDisplay = "..." + end + elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then + local mapped = self.overrideOptionsMap[globalValue] + if type(mapped) == "table" then + globalDisplay = mapped.text or mapped.label or globalValue + else + globalDisplay = tostring(mapped) + end + else + globalDisplay = tostring(globalValue or L["None"]) + end + + -- Show global value inline with label + self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) + self.overrideGlobalText:ClearAllPoints() + self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + + if isOverridden then + self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) + self.overrideCheckIcon:Hide() + else + self.overrideGlobalText:SetTextColor(0.3, 0.6, 0.3) + -- Position check icon after text + self.overrideCheckIcon:ClearAllPoints() + self.overrideCheckIcon:SetPoint("LEFT", self.overrideGlobalText, "RIGHT", 2, 0) + self.overrideCheckIcon:Show() + end + self.overrideGlobalText:Show() + end + + -- Register this widget for refresh tracking + table.insert(overrideWidgets, container) + + return container +end +P.AddOverrideIndicators = AddOverrideIndicators + +-- Override indicators for order list controls (drag lists) +-- These don't have traditional labels, so we use a compact star + reset + "Modified" badge +local function AddOrderListOverrideIndicators(container, dbKey, onReset) + -- Reset button (red, icon-only) + override marker (dot) — shared helpers. + local resetBtn = GUI:CreateOverrideResetButton(container, { + tooltip = L["Reset to Global Order"], + onClick = function() if onReset then onReset() end end, + }) + resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 14) + container.overrideResetBtn = resetBtn + + local starBtn = GUI:CreateOverrideMarker(container) + starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) + container.overrideStar = starBtn + local starIcon = starBtn.icon -- the "Modified" badge below anchors to the dot + + -- "Modified" text to the left of star + local modifiedText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + modifiedText:SetPoint("RIGHT", starIcon, "LEFT", -2, 0) + modifiedText:SetText(L["Modified"]) + modifiedText:SetTextColor(1, 0.8, 0.2, 0.8) + modifiedText:Hide() + container.overrideModifiedText = modifiedText + + -- Store dbKey for reference + container.overrideDbKey = dbKey + + -- Update function + container.UpdateOverrideIndicators = function(self, currentValue) + -- Debug mode + if S.overrideDebugMode then + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideModifiedText:SetText("Modified (debug)") + self.overrideModifiedText:Show() + return + end + + -- Only show when in raid mode and editing + local GUI = DF.GUI + if not GUI or GUI.SelectedMode ~= "raid" then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideModifiedText:Hide() + return + end + + local AutoProfilesUI = DF.AutoProfilesUI + if not AutoProfilesUI or not AutoProfilesUI:IsEditing() then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideModifiedText:Hide() + return + end + + local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) + + if isOverridden then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideModifiedText:Show() + else + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideModifiedText:Hide() + end + end + + -- Register for refresh tracking + table.insert(overrideWidgets, container) +end +P.AddOrderListOverrideIndicators = AddOrderListOverrideIndicators + +-- ============================================================ +-- SHARED CHECK / RADIO LOOK — single source of truth +-- Applies the standard square look to a CheckButton: element +-- backdrop + a pixel-snapped, themed WHITE8x8 check. Every box +-- and radio (the full builders below AND the hand-rolled ones +-- elsewhere) should call this, so a restyle is ONE edit. +-- opts.size box size (default 18) +-- opts.checkSize check-square size (default 10) +-- opts.accent fixed tint {r,g,b} — e.g. ClickCasting's green. +-- Omit to follow the party/raid theme (and auto- +-- register a theme listener on opts.themeRoot). +-- opts.themeRoot frame whose .ThemeListeners drive recolor +-- (default the button's parent); only used when +-- no accent is given. +-- Returns the check texture (also stored as cb.Check). +-- ============================================================ +function GUI:StyleCheckButton(cb, opts) + opts = opts or {} + PixelUtil.SetSize(cb, opts.size or 18, opts.size or 18) + CreateElementBackdrop(cb) + + local check = cb.Check or cb:CreateTexture(nil, "OVERLAY") + check:SetTexture("Interface\\Buttons\\WHITE8x8") + local cs = opts.checkSize or 10 + PixelUtil.SetSize(check, cs, cs) + PixelUtil.SetPoint(check, "CENTER", cb, "CENTER", 0, 0) + + local accent = opts.accent + local col = accent or GetThemeColor() + cb.Check = check + -- Native checkboxes let WoW show/hide the check via the checked state; a few + -- consumers (and plain Button-based pseudo-checkboxes) drive it manually via + -- cb.Check:SetShown(). opts.manualCheck supports those without SetCheckedTexture. + if opts.manualCheck then + check:Hide() + else + cb:SetCheckedTexture(check) + end + + -- Hover feedback: a subtle accent wash on the native HIGHLIGHT layer. WoW + -- shows it on mouseover automatically, so it works regardless of any OnEnter + -- the consumer sets (no clobbering), and it doesn't recolor the 1px border + -- (which can render unevenly at fractional UI scales). + local hl = cb:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture("Interface\\Buttons\\WHITE8x8") + hl:SetAllPoints(cb) + cb.Highlight = hl + + -- Single source for the themed tint: check colour + hover-wash strength. + -- Consumers that drive their own theme refresh call cb.ApplyThemeColor(c) too, + -- so the wash alpha (0.35) is defined in exactly one place. + cb.ApplyThemeColor = function(c) + check:SetVertexColor(c.r, c.g, c.b) + hl:SetVertexColor(c.r, c.g, c.b, 0.35) + end + cb.ApplyThemeColor(col) + + if not accent then + cb.UpdateTheme = function() + cb.ApplyThemeColor(GetThemeColor()) + end + local root = opts.themeRoot or cb:GetParent() + if root then + root.ThemeListeners = root.ThemeListeners or {} + table.insert(root.ThemeListeners, cb) + end + end + return check +end + +function GUI:CreateCheckbox(parent, label, dbTable, dbKey, callback, customGet, customSet, overrideKey) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(220, 24) + container.preferredHeight = GUI.RowHeight.checkbox -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "checkbox" -- /df debug gapcheck groups the spacing report by this + container.fixedRowHeight = true + + local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") + cb:SetPoint("LEFT", 0, 0) + -- Box + themed check come from the shared styler (single source of truth). + GUI:StyleCheckButton(cb, { themeRoot = parent }) + + -- Label + local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + txt:SetText(label) + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + container.label = txt -- exposed so callers can re-font / anchor a subtitle + + -- Determine the key to use for override indicators + local effectiveOverrideKey = overrideKey or dbKey + + -- Add override indicators if we have a key (either dbKey or overrideKey) + if effectiveOverrideKey and type(effectiveOverrideKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(effectiveOverrideKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(effectiveOverrideKey) + cb:SetChecked(globalVal) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + elseif customSet then + customSet(globalVal) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(container, txt, effectiveOverrideKey, onReset, nil, nil, dbTable) + end + + local function UpdateState() + local val = false + if customGet then val = customGet() elseif dbTable and dbKey then val = dbTable[dbKey] end + cb:SetChecked(val) + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + + container:SetScript("OnShow", UpdateState) + -- Re-read the source and repaint the box, for a caller that changed the value + -- behind the widget's back (a "set all" button). Same contract as + -- CreateSegmentToggle:Refresh(); before this, call sites reached for a + -- Hide()/Show() bounce to fire the OnShow above. + container.Refresh = UpdateState + cb:SetScript("OnClick", function(self) + local val = self:GetChecked() + -- Was gated on DF.debugEnabled and printed straight to CHAT, bypassing the + -- console entirely. GUI is the right category and it is already declared. + DF:Debug("GUI", "checkbox OnClick: dbKey=%s overrideKey=%s value=%s", + tostring(dbKey), tostring(overrideKey), tostring(val)) + + -- Runtime override protection: redirect to baseline, skip refresh + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(effectiveOverrideKey, val) then + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end + return + end + + if customSet then customSet(val) elseif dbTable and dbKey then dbTable[dbKey] = val end + + -- If editing a profile, also set the override (use effectiveOverrideKey) + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and effectiveOverrideKey then + DF.AutoProfilesUI:SetProfileSetting(effectiveOverrideKey, val) + end + + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + + if callback then + DF:Debug("GUI", "checkbox OnClick: calling callback") + callback() + end + if parent.RefreshStates then + DF:Debug("GUI", "checkbox OnClick: calling RefreshStates") + parent:RefreshStates() + end + DF:Debug("GUI", "checkbox OnClick: calling DF:UpdateAll") + DF:UpdateAll() + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget (box + check fill + label) so a disabled CHECKED + -- box greys too: native SetEnabled has no DisabledCheckedTexture, so the + -- accent check would otherwise stay full-bright. + self:SetAlpha(enabled and 1 or 0.4) + cb:SetEnabled(enabled) + if enabled then + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The + -- earlier hit-rect arithmetic here is gone with it — the hit frame is anchored + -- to the FontString, so a label overflowing the fixed 220 container is covered + -- for free rather than by widening the container's hit rect to match. + GUI:AttachTooltip(container, label, txt) + + UpdateState() + + -- SEARCH: Register this setting + if DF.Search then + local hasCustomGetSet = (customGet ~= nil or customSet ~= nil) + if dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterCheckbox(label, dbKey, nil, false, callback) + elseif hasCustomGetSet then + container.searchEntry = DF.Search:RegisterCheckbox(label, nil, nil, true, callback) + end + end + + return container +end + +-- ============================================================ +-- SEGMENT TOGGLE +-- A compact segmented control: the labels sit ON the buttons, all +-- of them boxed inside one recessed track so the pair reads as a +-- single control rather than two loose buttons. This is the +-- "Border Mode: [Shared][Custom]" idiom (AuraDesigner/Options.lua) +-- with the track added; use it for short mutually-exclusive values +-- that want to sit next to the field they qualify (s / %). +-- +-- API: GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) +-- segments : ordered { value =, label =, tooltip = } — label is what +-- shows on the button, tooltip the full name behind a terse one +-- opts.segmentWidth (26) / opts.height (18) +-- opts.fallbackValue : treated as selected when the stored value matches +-- no segment, so an unset key still lights the right button +-- opts.customGet / opts.customSet : same convention as CreateCheckbox / +-- CreateSlider / CreateDropdown — for a toggle over TRANSIENT UI +-- state (or one with its own save path) rather than a db key. Pass +-- dbTable/dbKey as nil when using these. +-- Returns the container with :Refresh(), :refreshContent() and :SetEnabled(). +-- ============================================================ +function GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) + opts = opts or {} + local segW = opts.segmentWidth or 26 + local h = opts.height or 18 + local pad = 1 -- track lip around the buttons + + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + container:SetSize(segW * #segments + pad * 2, h + pad * 2) + CreateElementBackdrop(container) -- the recessed track behind every segment + + -- One read/write pair for both pathways, so the click handler and Refresh + -- can't drift apart. Explicit ifs, not `a and b or c` — a stored value of + -- false/nil is legitimate. + local function GetValue() + if opts.customGet then return opts.customGet() end + if dbTable and dbKey then return dbTable[dbKey] end + end + local function SetValue(v) + if opts.customSet then opts.customSet(v) return true end + if dbTable and dbKey then dbTable[dbKey] = v return true end + return false + end + + local buttons = {} + for i, seg in ipairs(segments) do + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + GUI:StyleButton(btn, { width = segW, height = h, text = seg.label }) + GUI:SetSettingsFont(btn.Text, 9, "") + btn:SetPoint("TOPLEFT", container, "TOPLEFT", pad + (i - 1) * segW, -pad) + btn.value = seg.value + if seg.tooltip then + btn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = seg.tooltip, lines = opts.tooltipLines }) + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + btn:SetScript("OnClick", function(self) + if GetValue() == self.value then return end + if not SetValue(self.value) then return end + container:Refresh() + if callback then callback(self.value) end + end) + buttons[i] = btn + end + + -- Selection: the shared accent border/fill via SetActive, plus a bright/dim + -- label so the state still reads at a glance in a themed accent that is close + -- to the resting border colour. + function container:Refresh() + local cur = GetValue() + local matched = false + for _, b in ipairs(buttons) do if b.value == cur then matched = true end end + if not matched then cur = opts.fallbackValue end + for _, b in ipairs(buttons) do + local on = (b.value == cur) + b:SetActive(on) + if b.Text then + local c = on and C_TEXT or C_TEXT_DIM + b.Text:SetTextColor(c.r, c.g, c.b) + end + end + end + container.refreshContent = function(self) self:Refresh() end + + container.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + for _, b in ipairs(buttons) do b:EnableMouse(enabled) end + end + + container.UpdateTheme = function() container:Refresh() end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, container) + + container:Refresh() + return container +end + + +-- ============================================================ +-- DEBUG CATEGORY ROW +-- A wide row with checkbox + bold category name + description. +-- The whole row is clickable, hover shows a background highlight, +-- and the description is also surfaced as a tooltip on hover so it +-- remains accessible even if it gets visually truncated. +-- +-- Used by the Debug > Categories sub-tab. The categoryKey writes +-- directly to DandersFramesDB_v2.debug.filters. +-- ============================================================ +-- opts.noisy marks a firehose category: one user action can produce dozens of +-- lines, which evicts the trace the log was opened to capture. It renders as the +-- shared caution icon with its own tooltip rather than a "(noisy)" suffix baked +-- into the description string -- the suffix was untranslatable in place, and it +-- competed with the description for the same line of text. +function GUI:CreateDebugCategoryRow(parent, categoryKey, description, width, noisy) + local row = CreateFrame("Frame", nil, parent) + row:SetSize(width or 520, 28) + row:EnableMouse(true) + + -- Hover background + row.hoverBg = row:CreateTexture(nil, "BACKGROUND") + row.hoverBg:SetAllPoints() + row.hoverBg:SetColorTexture(1, 1, 1, 0.05) + row.hoverBg:Hide() + + -- Checkbox + local cb = CreateFrame("CheckButton", nil, row, "BackdropTemplate") + cb:SetPoint("LEFT", 4, 0) + GUI:StyleCheckButton(cb, { size = 16, checkSize = 9, themeRoot = parent }) + cb:EnableMouse(false) -- forward clicks to the row + + -- Category name (bold, full opacity) + local nameTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlight") + nameTxt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + nameTxt:SetWidth(86) + nameTxt:SetJustifyH("LEFT") + nameTxt:SetText(categoryKey) + nameTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Caution icon for a firehose category. Its own hit area, so it can carry a + -- different tooltip from the row without stealing the row's click: the frame + -- only enables mouse, it has no OnMouseUp, so a click still falls through to + -- the row underneath and toggles the category like anywhere else on it. + local noisyIcon + if noisy then + noisyIcon = CreateFrame("Frame", nil, row) + noisyIcon:SetSize(14, 14) + noisyIcon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + noisyIcon:EnableMouse(true) + noisyIcon:SetFrameLevel(row:GetFrameLevel() + 2) + local tex = noisyIcon:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + tex:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning") + -- The caution tone's ICON colour, read straight from the shared tone table + -- so this stays in step with every banner and note that uses it. + local ic = INFO_BANNER_TONES.caution.iconColor + tex:SetVertexColor(ic[1], ic[2], ic[3]) + noisyIcon:SetScript("OnEnter", function(self) + -- Keep the row's wash up: the pointer is still over the row, and + -- letting it drop would read as the row losing focus. + row.hoverBg:Show() + GUI:ShowTooltip(self, { + title = L["Noisy category"], + lines = { L["This category can fill the log very quickly, burying the entries you are looking for."], + L["Turn it on only while reproducing the bug it relates to."] }, + tone = "caution", + }) + end) + noisyIcon:SetScript("OnLeave", function() + row.hoverBg:Hide() + GUI:HideTooltip() + end) + row.noisyIcon = noisyIcon + end + + -- Description (dim, fills remaining space, wraps if too long) + if description and description ~= "" then + local descTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + descTxt:SetPoint("LEFT", nameTxt, "RIGHT", 12, 0) + -- Stop short of the icon rather than running under it. + if noisyIcon then + descTxt:SetPoint("RIGHT", noisyIcon, "LEFT", -6, 0) + else + descTxt:SetPoint("RIGHT", row, "RIGHT", -8, 0) + end + descTxt:SetJustifyH("LEFT") + descTxt:SetText(description) + descTxt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + row.descTxt = descTxt + end + + -- State helpers — read/write filters[categoryKey] + -- Absent or true = logged, explicit false = not logged + row.RefreshState = function() + local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters + local checked = (not filters) or filters[categoryKey] ~= false + cb:SetChecked(checked) + end + + local function ToggleState() + local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters + if not filters then return end + -- Toggle: false -> true, anything else -> false + if filters[categoryKey] == false then + filters[categoryKey] = true + else + filters[categoryKey] = false + end + row.RefreshState() + if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end + end + + row:SetScript("OnMouseUp", function(_, button) + if button == "LeftButton" then ToggleState() end + end) + + row:SetScript("OnEnter", function(self) + self.hoverBg:Show() + if description and description ~= "" then + GUI:ShowTooltip(self, { title = categoryKey, lines = { description } }) + end + end) + row:SetScript("OnLeave", function(self) + self.hoverBg:Hide() + GUI:HideTooltip() + end) + + row:SetScript("OnShow", row.RefreshState) + row.RefreshState() + + return row +end + +-- The one input "well": translucent-black fill + dim edge. Shared by StyleEditBox +-- (the single-line field) and CreateTextArea (the scrolling multi-line container) +-- so a text area and a text field read as the same control at two sizes. Passed +-- straight to CreateElementBackdrop, which only reads them. +local INPUT_FILL = { 0, 0, 0, 0.5 } +local INPUT_EDGE = { 0.3, 0.3, 0.3, 1 } + +-- StyleEditBox: normalize a bare (label-less) EditBox to the standard input +-- chrome used by CreateInput/CreateEditBox — translucent-black fill + dim border +-- + standard font/insets. The caller still owns size/position/scripts. Pass +-- opts.skipFont to keep a custom font (e.g. multi-line / monospace inputs). +function GUI:StyleEditBox(eb, opts) + opts = opts or {} + CreateElementBackdrop(eb, { + bgColor = INPUT_FILL, + borderColor = INPUT_EDGE, + }) + if not opts.skipFont then + eb:SetFontObject(DFFontHighlightSmall) + eb:SetTextInsets(5, 5, opts.multiline and 5 or 0, opts.multiline and 5 or 0) + end + -- Multiline mode: for text areas (export/import blobs, macro bodies). The + -- caller owns the ScrollFrame/sizing; this just flags the editbox + relaxes + -- the vertical insets. Enter inserts a newline (no auto clear-focus). + if opts.multiline then + eb:SetMultiLine(true) + eb:SetAutoFocus(false) + end + return eb +end + +function GUI:CreateInput(parent, label, width) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 180, 44) + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local editbox = CreateFrame("EditBox", nil, frame) + -- Two-corner anchored, so the offset and the height are the ONLY levers -- + -- Nothing corrects a frame's position at runtime, and controls are not + -- position-corrected at all any more. Snap both and all four edges land. + local ebY = SnapLen(editbox, -15) or -15 + editbox:SetPoint("TOPLEFT", 0, ebY) + editbox:SetPoint("TOPRIGHT", 0, ebY) + editbox:SetHeight(SnapLen(editbox, 24) or 24) + GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets + editbox:SetAutoFocus(false) + editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editbox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) + + -- Grey-when-disabled parity with CreateEditBox (cheap insurance if ever placed + -- in a gated group): dim the whole widget + block editing. + frame.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + editbox:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + frame.EditBox = editbox + -- Tooltip: shared attach on the LABEL only. This factory carried no tooltip + -- support at all, so a caller that set .tooltip on it got silence — + -- Options.lua's custom range-spell input did exactly that, and its + -- explanation never appeared. Keeping it off the edit box also means it can't + -- cover what you are typing. + GUI:AttachTooltip(frame, label, lbl) + return frame +end + +-- CreateEditBox: Text input with db binding (for settings like custom text) +function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 180, 44) + frame.preferredHeight = GUI.RowHeight.editbox -- factory-owned slot height (see GUI.RowHeight) + frame.rowKind = "editbox" + frame.fixedRowHeight = true + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + end + if frame.EditBox then + frame.EditBox:SetText(globalVal or "") + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(frame, lbl, dbKey, onReset, 6, nil, dbTable) + end + + local editbox = CreateFrame("EditBox", nil, frame) + -- Two-corner anchored, so the offset and the height are the ONLY levers -- + -- Nothing corrects a frame's position at runtime, and controls are not + -- position-corrected at all any more. Snap both and all four edges land. + local ebY = SnapLen(editbox, -15) or -15 + editbox:SetPoint("TOPLEFT", 0, ebY) + editbox:SetPoint("TOPRIGHT", 0, ebY) + editbox:SetHeight(SnapLen(editbox, 24) or 24) + GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets + editbox:SetAutoFocus(false) + + -- Set initial value from db + if dbTable and dbKey then + editbox:SetText(dbTable[dbKey] or "") + end + + -- Save on enter or focus lost + local function SaveValue() + if dbTable and dbKey then + local val = editbox:GetText() + -- Runtime override protection + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then + if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(val) end + return + end + dbTable[dbKey] = val + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + DF.AutoProfilesUI:SetProfileSetting(dbKey, val) + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(val) + end + if callback then callback() end + end + end + + editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editbox:SetScript("OnEnterPressed", function(self) + SaveValue() + self:ClearFocus() + end) + editbox:SetScript("OnEditFocusLost", SaveValue) + + -- Optional placeholder: greyed example text shown while the box is empty + -- and unfocused. Purely cosmetic — never written to the db. + if placeholder and placeholder ~= "" then + local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") + ph:SetPoint("LEFT", 5, 0) + ph:SetPoint("RIGHT", -5, 0) + ph:SetJustifyH("LEFT") + ph:SetText(placeholder) + ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) + local function UpdatePlaceholder() + ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") + end + -- Exposed so a caller that puts something INSIDE the box (see + -- GUI:AddEditBoxIcon) can move the placeholder clear of it. + editbox.Placeholder = ph + editbox.UpdatePlaceholder = UpdatePlaceholder + editbox:HookScript("OnTextChanged", UpdatePlaceholder) + editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) + editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) + UpdatePlaceholder() + end + + -- Refresh override indicators on show + frame:SetScript("OnShow", function() + if dbTable and dbKey then + editbox:SetText(dbTable[dbKey] or "") + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end + end) + + -- Grey-when-disabled: the grey loop (RefreshChildStates) calls widget:SetEnabled, + -- but this frame had none, so a disabled group left the input full-bright AND + -- editable. Dim the whole widget + block editing, matching the other helpers. + frame.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + editbox:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + frame.EditBox = editbox + return frame +end + +-- ============================================================ +-- LEADING ICON INSIDE AN EDIT BOX +-- The search-bar look from the main addon search (Features/Search.lua), made +-- available to any CreateEditBox rather than re-rolled per search field: the +-- glyph, the same 0.72 grey, and — the part that is easy to forget — the text +-- inset AND the placeholder both moved clear of it, so neither the typed text +-- nor the "Search..." hint runs underneath the icon. +-- +-- Pass frame.EditBox, not the frame. +-- ============================================================ +function GUI:AddEditBoxIcon(editbox, texture, size) + if not editbox or not texture then return end + size = size or 14 + local icon = editbox:CreateTexture(nil, "OVERLAY") + icon:SetSize(size, size) + icon:SetPoint("LEFT", 6, 0) + icon:SetTexture(texture) + icon:SetVertexColor(0.72, 0.72, 0.72) + editbox.Icon = icon + + local left = 6 + size + 5 + local _, right, top, bottom = editbox:GetTextInsets() + editbox:SetTextInsets(left, right, top, bottom) + if editbox.Placeholder then + editbox.Placeholder:SetPoint("LEFT", left, 0) + end + return icon +end + +-- ============================================================ +-- TEXT AREA — the multi-line cousin of CreateEditBox +-- A bordered well holding a scrolling multi-line EditBox. Eight surfaces built +-- this same container + ScrollFrame + EditBox stack by hand (export/import blobs, +-- the debug log viewer and script runner, the macro body, the popup's input mode, +-- the changelog), each picking its own well colour and three of them forgetting +-- the click-to-focus, so clicking the empty space below the text did nothing. +-- One owner, and the same well as every single-line input. +-- +-- opts: +-- width, height size the well; omit and anchor it yourself +-- text initial contents +-- fontObject default DFFontHighlightSmall +-- fontSize, fontFlags use the settings font at a size instead of a font object +-- maxLetters +-- readOnly show-and-copy (export strings, the changelog): user +-- edits bounce back, but it stays selectable + copyable +-- autoFocus take focus and select all on creation (copy-me popups) +-- onTextChanged(text, userInput) +-- onEscape(editBox) default: clear focus +-- bgColor, borderColor override the standard input well +-- plain skip the well entirely — for a text area that fills a +-- surface which already has its own panel (the changelog) +-- insets text insets, default 4 +-- Returns the well, with .EditBox / .ScrollFrame and SetText / GetText / +-- HighlightText / SetFocus / ClearFocus / SetEnabled forwarded to the field. +-- ============================================================ +local TEXTAREA_PAD = 4 -- gap between the well's edge and the scroll frame +local TEXTAREA_GUTTER = 18 -- room right of the scroll for the themed scrollbar + +function GUI:CreateTextArea(parent, opts) + opts = opts or {} + + local well = CreateFrame("Frame", nil, parent, "BackdropTemplate") + if opts.width and opts.height then well:SetSize(opts.width, opts.height) end + local pad = opts.plain and 0 or TEXTAREA_PAD + if not opts.plain then + CreateElementBackdrop(well, { + bgColor = opts.bgColor or INPUT_FILL, + borderColor = opts.borderColor or INPUT_EDGE, + }) + end + + local scroll = CreateFrame("ScrollFrame", nil, well, "ScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", pad, -pad) + scroll:SetPoint("BOTTOMRIGHT", -(pad + TEXTAREA_GUTTER), pad) + StyleScrollBar(scroll) + + local eb = CreateFrame("EditBox", nil, scroll) + eb:SetMultiLine(true) + eb:SetAutoFocus(false) + if opts.fontSize then + GUI:SetSettingsFont(eb, opts.fontSize, opts.fontFlags or "") + else + eb:SetFontObject(opts.fontObject or DFFontHighlightSmall) + end + eb:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local inset = opts.insets or (opts.plain and 0 or TEXTAREA_PAD) + eb:SetTextInsets(inset, inset, inset, inset) + if opts.maxLetters then eb:SetMaxLetters(opts.maxLetters) end + eb:SetScript("OnEscapePressed", opts.onEscape or function(s) s:ClearFocus() end) + scroll:SetScrollChild(eb) + + -- A scroll child has to be told its size, and that is only known once the + -- well has been sized or its anchors have resolved — which for an anchored + -- (rather than SetSize'd) well is not this frame. So do it here AND on every + -- resize, which is also what lets a text area sit in a resizable window + -- without the caller re-setting the width by hand on every show. + -- + -- Height is seeded ONCE and then left alone: after that the field owns it, + -- growing its own rect as text is added, which is what makes the scroll + -- frame scroll. Re-seeding on resize would clamp a grown field back down. + local heightSeeded = false + local function SyncSize(w, h) + if w and w > 0 then eb:SetWidth(w) end + if h and h > 0 and not heightSeeded then + heightSeeded = true + eb:SetHeight(h) + end + end + scroll:SetScript("OnSizeChanged", function(_, w, h) SyncSize(w, h) end) + SyncSize(scroll:GetWidth(), scroll:GetHeight()) + + -- Clicking anywhere in the well lands in the field, not just on the text + -- itself — the contents rarely fill the box. + well:EnableMouse(true) + well:SetScript("OnMouseDown", function() eb:SetFocus() end) + scroll:EnableMouse(true) + scroll:SetScript("OnMouseDown", function() eb:SetFocus() end) + + well.EditBox, well.ScrollFrame = eb, scroll + well.GetText = function(_) return eb:GetText() end + well.HighlightText = function(_, ...) eb:HighlightText(...) end + well.SetFocus = function(_) eb:SetFocus() end + well.ClearFocus = function(_) eb:ClearFocus() end + well.SetText = function(_, text) + eb:SetText(text or "") + eb:SetCursorPosition(0) -- long blobs open at the top, not the tail + end + + -- Grey-when-disabled, per the GUI conventions: dim the whole widget AND stop + -- it accepting edits. + well.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + eb:SetEnabled(enabled) + end + + -- WoW has no read-only EditBox. Bouncing the text back on any USER change + -- keeps Ctrl+A / Ctrl+C working, which EnableKeyboard(false) would not. + if opts.readOnly then + local locked = opts.text or "" + eb:SetScript("OnTextChanged", function(s, user) + if user and s:GetText() ~= locked then + s:SetText(locked) + s:HighlightText() + end + end) + well.SetText = function(_, text) + locked = text or "" + eb:SetText(locked) + eb:SetCursorPosition(0) + end + elseif opts.onTextChanged then + eb:SetScript("OnTextChanged", function(s, user) + opts.onTextChanged(s:GetText(), user) + end) + end + + if opts.text then well:SetText(opts.text) end + if opts.autoFocus then + eb:SetAutoFocus(true) + eb:SetFocus() + eb:HighlightText() + end + + return well +end + +-- customGet / customSet (optional, matches CreateDropdown's pattern): when +-- provided, the slider routes its reads and writes through these functions +-- instead of dbTable[dbKey] directly. Used by widgets whose underlying value +-- lives inside a nested table (e.g. Border Alpha → BorderColor.a), +-- where the plain `dbTable[dbKey] = v` path can't express the nesting. +-- Consumers that pass customSet typically pass dbKey = nil so the +-- auto-profile override system doesn't track a key that doesn't exist at the +-- top level of dbTable. +-- accentColor (optional {r,g,b}): fixed thumb/fill colour instead of the mode +-- theme — for ClickCasting (green) / Search (blue) which keep their identity. + +function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, callback, lightweightUpdate, usePreviewMode, customGet, customSet, accentColor) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 50) + container.preferredHeight = GUI.RowHeight.slider -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "slider" + container.fixedRowHeight = true + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided (for auto profiles) + -- Use vertical offset of 6 to align with label row (sliders have input box below) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + dbTable[dbKey] = globalVal + -- Update slider display + if container.slider then + container.slider:SetValue(globalVal) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) + end + + -- Background track. + -- Left edge and height here; the RIGHT edge is pinned to the value box further + -- down, once that exists. The track used to be a fixed 180px while a dropdown + -- anchors TOPLEFT+TOPRIGHT and fills its container, so on any panel wider than + -- the 260 default the two controls ended at visibly different x positions -- + -- and drifted further apart the wider the panel got. Both are container-driven + -- now, so they line up at any width instead of at one magic number. + local track = CreateFrame("Frame", nil, container, "BackdropTemplate") + track:SetPoint("TOPLEFT", 0, SnapLen(track, -18) or -18) + track:SetHeight(SnapLen(track, 8) or 8) + CreateElementBackdrop(track) + + -- Fill track (colored portion) + local fill = track:CreateTexture(nil, "ARTWORK") + fill:SetPoint("LEFT", 1, 0) + fill:SetHeight(6) + local c = accentColor or GetThemeColor() + fill:SetColorTexture(c.r, c.g, c.b, 0.8) + + -- Slider + local slider = CreateFrame("Slider", nil, container) + -- Same snapped offset/height as the track it sits on, or the invisible hit + -- area drifts off the visible bar by a fraction of a pixel. + slider:SetPoint("TOPLEFT", 0, SnapLen(slider, -18) or -18) + slider:SetHeight(SnapLen(slider, 8) or 8) -- right edge pinned to the value box, same as the track + slider:SetOrientation("HORIZONTAL") + slider:SetMinMaxValues(minVal, maxVal) + slider:SetValueStep(step) + slider:SetObeyStepOnDrag(true) + slider:SetHitRectInsets(-4, -4, -8, -8) + container.slider = slider -- Store reference for reset + + -- Track whether this slider is actively being dragged + local isDragging = false + + -- Store preview mode flag for this slider + local sliderUsePreviewMode = usePreviewMode or false + + -- Thumb + local thumb = slider:CreateTexture(nil, "OVERLAY") + thumb:SetSize(12, 16) + thumb:SetColorTexture(c.r, c.g, c.b, 1) + slider:SetThumbTexture(thumb) + + -- Value input + local input = CreateFrame("EditBox", nil, container, "BackdropTemplate") + input:SetSize(50, 20) + -- Pinned to the container's RIGHT edge -- the same edge a dropdown's opener + -- ends on -- and the track/slider then stretch from the left to meet it. + -- y = -12 keeps the 20px box centred on the 8px track at -18. + input:SetPoint("TOPRIGHT", 0, -12) + track:SetPoint("RIGHT", input, "LEFT", -8, 0) + slider:SetPoint("RIGHT", input, "LEFT", -8, 0) + CreateElementBackdrop(input) + input:SetFontObject(DFFontHighlightSmall) + input:SetJustifyH("CENTER") + input:SetAutoFocus(false) + input:SetTextInsets(2, 2, 0, 0) + + local function UpdateFill() + local val = slider:GetValue() + local pct = (val - minVal) / (maxVal - minVal) + -- Measured off the LIVE track, not the old hardcoded 178 (= the fixed 180 + -- track minus the fill's 1px inset each side). The track stretches now, so + -- a constant here would under-fill on any panel wider than the default. + local usable = (track:GetWidth() or 0) - 2 + if usable < 1 then usable = 1 end + fill:SetWidth(math.max(1, pct * usable)) + end + -- The track's width is only known once the page layout has resolved its + -- anchors, and changes again if the panel is resized -- so repaint the fill + -- whenever it does, or the bar renders at its pre-layout width. + track:SetScript("OnSizeChanged", function() UpdateFill() end) + + container.SetEnabled = function(self, enabled) + slider:SetEnabled(enabled) + -- Grey the numeric value box too: it was only EnableMouse'd (clicks blocked + -- but still full-bright + typeable), so it stayed lit while the track dimmed. + input:EnableMouse(enabled) + input:SetEnabled(enabled) + input:SetAlpha(enabled and 1 or 0.4) + local tc = accentColor or GetThemeColor() + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + thumb:SetColorTexture(tc.r, tc.g, tc.b, 1) + fill:SetColorTexture(tc.r, tc.g, tc.b, 0.8) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + thumb:SetColorTexture(0.4, 0.4, 0.4, 1) + fill:SetColorTexture(0.4, 0.4, 0.4, 0.5) + end + end + + container.UpdateTheme = function() + local nc = accentColor or GetThemeColor() + if slider:IsEnabled() then + thumb:SetColorTexture(nc.r, nc.g, nc.b, 1) + fill:SetColorTexture(nc.r, nc.g, nc.b, 0.8) + end + end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, container) + + local suppressCallback = false + + -- Smart format: show whole numbers as integers, decimals with minimum precision needed + local function FormatValue(val) + if val == math.floor(val) then + return string.format("%d", val) + elseif val * 10 == math.floor(val * 10) then + return string.format("%.1f", val) + else + return string.format("%.2f", val) + end + end + + -- Wrapper for both pathways: customGet/Set when provided, dbTable[dbKey] + -- otherwise. Centralising this avoids a sprinkling of `if customGet then` + -- across every place the slider touches its value. + local function ReadValue() + if customGet then return customGet() end + if dbTable then return dbTable[dbKey] end + return nil + end + local function WriteValue(v) + if customSet then return customSet(v) end + if dbTable then dbTable[dbKey] = v end + end + + local function UpdateValue(val) + val = val or minVal + suppressCallback = true + slider:SetValue(val) + suppressCallback = false + input:SetText(FormatValue(val)) + UpdateFill() + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + + -- Re-read the bound value (customGet or dbTable[dbKey]) and redraw. For a slider whose + -- source can change under it — e.g. one bound through customGet to whichever key an + -- account-wide unit dial currently selects — called from refreshContent. + container.RefreshValue = function(self) + local v = ReadValue() + if v ~= nil then UpdateValue(math.max(minVal, math.min(maxVal, v))) end + end + -- Runtime range. A slider whose UNIT is decided elsewhere (seconds vs percent) is built + -- once but must re-scale in place, or flipping that dial leaves a 1-60 track in front of + -- a percentage until the panel is rebuilt. Pair with container.label for the caption. + container.SetRange = function(self, newMin, newMax) + if newMin ~= minVal or newMax ~= maxVal then + minVal, maxVal = newMin, newMax + slider:SetMinMaxValues(minVal, maxVal) + end + self:RefreshValue() + end + + -- Track drag start - pass the lightweight update function, name for debug, and preview mode + slider:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + isDragging = true + local funcName = lightweightUpdate and ((dbKey or label or "slider") .. " lightweight") or nil + DF:OnSliderDragStart(lightweightUpdate, funcName, sliderUsePreviewMode) + end + end) + + -- Track drag end - do full update when slider is released + slider:SetScript("OnMouseUp", function(self, button) + if button == "LeftButton" and isDragging then + isDragging = false + DF:OnSliderDragStop() + -- Update override indicators after drag ends + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(slider:GetValue()) + end + end + end) + + slider:SetScript("OnShow", function() + local v = ReadValue() + if v ~= nil then UpdateValue(v) end + end) + + slider:SetScript("OnValueChanged", function(self, value) + if suppressCallback then return end + if not (dbTable or customSet) then return end + if step >= 1 then + value = math.floor(value + 0.5) + else + value = math.floor(value / step + 0.5) * step + end + + -- Runtime override protection: redirect to baseline, skip refresh + if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, value) then + if not input:HasFocus() then input:SetText(FormatValue(value)) end + UpdateFill() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(value) end + return + end + + WriteValue(value) + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, value) + end + + if not input:HasFocus() then + input:SetText(FormatValue(value)) + end + UpdateFill() + -- Use targeted update system - lightweight during drag, full on release + DF:ThrottledUpdateAll() + -- Skip callback during drag - it will run via UpdateAll on release + if callback and not DF.sliderDragging then + callback() + end + end) + + input:SetScript("OnEnterPressed", function(self) + local val = tonumber(self:GetText()) + if val then + val = math.max(minVal, math.min(maxVal, val)) + + -- Runtime override protection: redirect to baseline, skip refresh + if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then + self:SetText(FormatValue(val)) + suppressCallback = true + slider:SetValue(val) + suppressCallback = false + UpdateFill() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end + self:ClearFocus() + return + end + + WriteValue(val) + suppressCallback = true + slider:SetValue(val) + suppressCallback = false + + -- Update input text to show actual value entered + self:SetText(FormatValue(val)) + UpdateFill() + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, val) + end + + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + + -- FIX 2025-01-20: Call callback OR lightweightUpdate (some sliders have nil callback) + if callback then + callback() + elseif lightweightUpdate then + lightweightUpdate() + end + + -- Guaranteed full update (SetValue may not fire OnValueChanged if value didn't change) + DF:UpdateAll() + else + local v = ReadValue(); if v ~= nil then UpdateValue(v) end + end + self:ClearFocus() + end) + + input:SetScript("OnEscapePressed", function(self) + local v = ReadValue(); if v ~= nil then UpdateValue(v) end + self:ClearFocus() + end) + + local initial = ReadValue() + if initial ~= nil then UpdateValue(initial) end + + -- SEARCH: Register this setting with slider metadata + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterSlider(label, dbKey, minVal, maxVal, step, nil, callback) + end + + -- Expose label for dynamic updates + container.label = lbl + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). Keeping it + -- off the bar matters most here — a tooltip over a slider you are dragging is + -- the worst case of the problem. Both .tooltip (title from the label) and the + -- legacy .tooltipText/.tooltipSubText pair are honoured. + GUI:AttachTooltip(container, label, lbl) + + return container +end + +-- Stamp the shared Frame Level explanation onto a slider. One helper rather than the same +-- two strings at 21 call sites, and it keeps the wording in ONE place -- the old per-page +-- label went stale the moment the scale changed (it still read "0=Auto" afterwards). +-- Takes the CONTAINER that CreateSlider returns, which is what every call site has. +function GUI:SetFrameLevelTooltip(container) + if not container then return end + container.tooltipText = L["Frame Level"] + container.tooltipSubText = L["Higher numbers draw on top of lower ones. Every Frame Level in DandersFrames uses the same scale, counted up from the unit frame, so you can compare them directly."] + return container -- chainable, so it wraps a CreateSlider call in place +end + +-- Dual-handle range slider: two draggable handles select a [lo, hi] sub-range of +-- [minRange, maxRange]. Self-contained — the caller anchors the returned track +-- frame and reads values via the onChange callback. (:GetValues() also exists and +-- completes the SetValues pair, but no current consumer polls it.) Drag is +-- tracked on the track's own OnUpdate (no dependence on parent scripts), and a +-- mouse-button check releases the drag even if the cursor leaves the handle. +-- opts: +-- width(336), accent({r,g,b}=theme), minRange, maxRange, lo, hi, +-- scaleLabels({...} optional tick labels), scaleMin/scaleMax (label scale, +-- default minRange/maxRange — lets ticks stay on a fixed scale while the +-- handle range changes), display(FontString updated each change), +-- formatRange(fn(lo,hi)->str), formatOne(fn(v)->str), +-- onChange(fn(lo,hi) — fired on user-driven changes only, not SetValues). +-- Methods on the returned frame: :SetRange(min,max), :SetValues(lo,hi), +-- :GetValues()->lo,hi. +function GUI:CreateRangeSlider(parent, opts) + opts = opts or {} + local width = opts.width or 336 + local accent = opts.accent or GetThemeColor() + + local track = CreateFrame("Frame", nil, parent, "BackdropTemplate") + track:SetSize(width, 12) + CreateElementBackdrop(track, { + bgColor = { 0.03, 0.03, 0.03, 1 }, + borderColor = { 0.2, 0.2, 0.2, 1 }, + }) + + track.minRange = opts.minRange or 1 + track.maxRange = opts.maxRange or 40 + track.lo = opts.lo or track.minRange + track.hi = opts.hi or track.maxRange + + local rangeFill = track:CreateTexture(nil, "ARTWORK") + rangeFill:SetTexture("Interface\\Buttons\\WHITE8x8") + rangeFill:SetVertexColor(accent.r, accent.g, accent.b, 0.5) + rangeFill:SetHeight(10) + rangeFill:SetPoint("TOP", 0, -1) + + local function MakeHandle() + local h = CreateFrame("Button", nil, track) + h:SetSize(8, 16) + h:EnableMouse(true) + local tex = h:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + tex:SetTexture("Interface\\Buttons\\WHITE8x8") + tex:SetVertexColor(accent.r, accent.g, accent.b, 1) + return h + end + local minHandle, maxHandle = MakeHandle(), MakeHandle() + track.minHandle, track.maxHandle = minHandle, maxHandle + + local function ValueToPos(value) + local pct = (value - track.minRange) / (track.maxRange - track.minRange) + return pct * (width - 4) + 2 + end + local function PosToValue(pos) + local pct = (pos - 2) / (width - 4) + return math.floor(pct * (track.maxRange - track.minRange) + track.minRange + 0.5) + end + + local function Redraw() + local minPos, maxPos = ValueToPos(track.lo), ValueToPos(track.hi) + minHandle:ClearAllPoints() + minHandle:SetPoint("CENTER", track, "LEFT", minPos, 0) + maxHandle:ClearAllPoints() + maxHandle:SetPoint("CENTER", track, "LEFT", maxPos, 0) + rangeFill:ClearAllPoints() + rangeFill:SetPoint("LEFT", track, "LEFT", minPos, 0) + rangeFill:SetWidth(math.max(maxPos - minPos, 2)) + if opts.display then + if track.lo == track.hi then + opts.display:SetText(opts.formatOne and opts.formatOne(track.lo) or tostring(track.lo)) + else + opts.display:SetText(opts.formatRange and opts.formatRange(track.lo, track.hi) + or (track.lo .. " - " .. track.hi)) + end + end + end + + local dragging = nil + local function ApplyCursor() + local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() + local trackLeft = track:GetLeft() + if not trackLeft then return end + local pos = math.max(2, math.min(x - trackLeft, width - 2)) + local value = math.max(track.minRange, math.min(PosToValue(pos), track.maxRange)) + if dragging == "min" then + if value <= track.hi then track.lo = value end + elseif dragging == "max" then + if value >= track.lo then track.hi = value end + end + Redraw() + if opts.onChange then opts.onChange(track.lo, track.hi) end + end + + minHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "min" end end) + maxHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "max" end end) + track:SetScript("OnUpdate", function() + if not dragging then return end + if not IsMouseButtonDown("LeftButton") then dragging = nil; return end + ApplyCursor() + end) + + track:EnableMouse(true) + track:SetScript("OnMouseDown", function(_, b) + if b ~= "LeftButton" then return end + local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() + local trackLeft = track:GetLeft() + if not trackLeft then return end + local value = PosToValue(x - trackLeft) + if math.abs(value - track.lo) <= math.abs(value - track.hi) then + if value <= track.hi then track.lo = math.max(track.minRange, value) end + else + if value >= track.lo then track.hi = math.min(track.maxRange, value) end + end + Redraw() + if opts.onChange then opts.onChange(track.lo, track.hi) end + end) + + if opts.scaleLabels then + local sMin = opts.scaleMin or track.minRange + local sMax = opts.scaleMax or track.maxRange + for _, num in ipairs(opts.scaleLabels) do + local pct = (num - sMin) / (sMax - sMin) + local xPos = pct * (width - 4) + 2 + local lbl = track:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetText(num) + lbl:SetTextColor(0.35, 0.35, 0.35) + lbl:SetPoint("TOP", track, "BOTTOM", xPos - width / 2, -2) + end + end + + function track:SetRange(minR, maxR) + self.minRange, self.maxRange = minR, maxR + self.lo = math.max(minR, math.min(self.lo, maxR)) + self.hi = math.max(minR, math.min(self.hi, maxR)) + Redraw() + end + function track:SetValues(lo, hi) + self.lo = math.max(self.minRange, math.min(lo, self.maxRange)) + self.hi = math.max(self.minRange, math.min(hi, self.maxRange)) + Redraw() + end + function track:GetValues() return self.lo, self.hi end + + Redraw() + return track +end + +function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback, lightweightCallback, useLightweight) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 28) + container.preferredHeight = GUI.RowHeight.colorpicker -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "colorpicker" + container.fixedRowHeight = true + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, 0) + btn:SetPoint("TOPRIGHT", 0, 0) + btn:SetHeight(SnapLen(btn, 24) or 24) + CreateElementBackdrop(btn) + + -- Label + local txt = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", 8, 0) + txt:SetText(label) + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Color swatch + local swatch = btn:CreateTexture(nil, "OVERLAY") + swatch:SetSize(40, 16) + swatch:SetPoint("RIGHT", -6, 0) + + -- Add override indicators if dbKey is provided (for auto profiles) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if globalVal then + dbTable[dbKey].r = globalVal.r + dbTable[dbKey].g = globalVal.g + dbTable[dbKey].b = globalVal.b + dbTable[dbKey].a = globalVal.a or 1 + end + if container.UpdateSwatch then + container:UpdateSwatch() + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable[dbKey]) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, txt, dbKey, onReset, nil, nil, dbTable) + end + + local function UpdateSwatch() + if dbTable and dbKey and dbTable[dbKey] then + local c = dbTable[dbKey] + swatch:SetColorTexture(c.r, c.g, c.b, c.a or 1) + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(c) + end + end + end + container.UpdateSwatch = UpdateSwatch -- Expose for reset + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + -- Tooltip: shared attach on the LABEL only. This factory carried none, across + -- 87 colour pickers. The btn keeps its own hover scripts untouched — the hit + -- frame is over the text, not the swatch. + GUI:AttachTooltip(container, label, txt) + + btn:SetScript("OnClick", function() + if not dbTable then return end + local c = dbTable[dbKey] + if not c then + c = {r = 1, g = 1, b = 1, a = 1} + dbTable[dbKey] = c + end + + -- Store original values for cancel + local originalColor = {r = c.r, g = c.g, b = c.b, a = c.a or 1} + + -- Blizzard's SetupColorPickerAndShow fires swatchFunc once DURING + -- setup (its SetColorRGB triggers OnColorSelect — the source comments + -- it). That spurious fire re-writes the unchanged colour and runs the + -- change callbacks on mere open: it commits per-element override flags + -- (Text Designer) and triggers a pointless full refresh. Suppress + -- callbacks until setup has returned. + local settingUp = true + + local info = { + swatchFunc = function() + if settingUp then return end + local r, g, b = ColorPickerFrame:GetColorRGB() + local a = 1 + if hasAlpha and ColorPickerFrame.GetColorAlpha then + a = ColorPickerFrame:GetColorAlpha() or 1 + end + dbTable[dbKey].r = r + dbTable[dbKey].g = g + dbTable[dbKey].b = b + dbTable[dbKey].a = a + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, {r = r, g = g, b = b, a = a}) + end + + UpdateSwatch() + -- Use lightweight callback during dragging if available + if useLightweight and lightweightCallback then + lightweightCallback() + else + DF:ThrottledUpdateAll() + if callback then callback() end + end + end, + hasOpacity = hasAlpha, + opacityFunc = hasAlpha and function() + if settingUp then return end + if ColorPickerFrame.GetColorAlpha then + local a = ColorPickerFrame:GetColorAlpha() + if a then + dbTable[dbKey].a = a + UpdateSwatch() + -- Use lightweight callback during dragging if available + if useLightweight and lightweightCallback then + lightweightCallback() + else + DF:ThrottledUpdateAll() + if callback then callback() end + end + end + end + end or nil, + cancelFunc = function(restore) + -- Restore original color on cancel + dbTable[dbKey].r = originalColor.r + dbTable[dbKey].g = originalColor.g + dbTable[dbKey].b = originalColor.b + dbTable[dbKey].a = originalColor.a + UpdateSwatch() + DF:UpdateAll() + if callback then callback() end + end, + r = c.r or 1, + g = c.g or 1, + b = c.b or 1, + opacity = hasAlpha and (c.a or 1) or nil, + } + + -- Hook the OK button to run full update when confirmed + if useLightweight and lightweightCallback then + -- We need to run full update when picker is closed via OK + -- Use a frame to detect when color picker closes + if not container.colorPickerWatcher then + container.colorPickerWatcher = CreateFrame("Frame") + end + container.colorPickerWatcher:SetScript("OnUpdate", function(self) + if not ColorPickerFrame:IsShown() then + self:SetScript("OnUpdate", nil) + -- Only run if color changed (not cancelled) + local cur = dbTable[dbKey] + if cur.r ~= originalColor.r or cur.g ~= originalColor.g or + cur.b ~= originalColor.b or cur.a ~= originalColor.a then + DF:UpdateAll() + if callback then callback() end + end + end + end) + end + + -- Attach default colour so the picker can offer a Default button + -- dbTable.__dfDefaults is set by callers (e.g. Aura Designer proxies) that + -- store their defaults outside DF.PartyDefaults / DF.RaidDefaults. Read via + -- rawget so proxies' __index doesn't see this lookup as a regular setting. + local defaultVal = (dbTable and rawget(dbTable, "__dfDefaults") and dbTable.__dfDefaults[dbKey]) + or (DF.PartyDefaults and DF.PartyDefaults[dbKey]) + or (DF.RaidDefaults and DF.RaidDefaults[dbKey]) + -- Fallback: power bar colours use WoW's PowerBarColor table as their default + if not defaultVal and PowerBarColor and dbKey then + defaultVal = PowerBarColor[dbKey] + end + if defaultVal and type(defaultVal) == "table" and defaultVal.r then + info.dfDefaultColor = {r = defaultVal.r or 1, g = defaultVal.g or 1, b = defaultVal.b or 1, a = defaultVal.a or 1} + -- Populate ElvUI's "Default" button (ColorPPDefault) so it enables and + -- pastes the DF setting default when the native Blizzard picker is shown + local elvDefault = _G["ColorPPDefault"] + if elvDefault then + elvDefault.colors = info.dfDefaultColor + end + end + + -- Mark this as a DandersFrames color picker call + GUI:MarkColorPickerCall() + ColorPickerFrame:SetupColorPickerAndShow(info) + settingUp = false + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so the colour swatch greys even when it's a dark + -- colour (SetDesaturated alone is invisible on near-black swatches). + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + swatch:SetDesaturated(false) + else + txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + swatch:SetDesaturated(true) + end + end + + btn:SetScript("OnShow", UpdateSwatch) + UpdateSwatch() + + -- SEARCH: Register this setting + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterColorPicker(label, dbKey, hasAlpha, nil, callback) + end + + return container +end + +-- ============================================================ +-- OPEN-MENU REGISTRY +-- Dropdown/preset menus anchor to their button and survive context switches +-- that leave the button visible (e.g. the Aura Designer changing tabs). +-- Every menu frame registers here at creation; CloseAllMenus() lets a +-- context switch dismiss whatever is open. +-- ============================================================ +GUI._menus = GUI._menus or {} +function GUI:RegisterMenu(frame) self._menus[frame] = true end +function GUI:CloseAllMenus() + for f in pairs(self._menus) do + if f:IsShown() then f:Hide() end + end +end + +function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, customGet, customSet, opts) + opts = opts or {} + local accentColor = opts.accent + local optionsFunc = opts.optionsFunc + + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, opts.inline and 24 or 50) + -- Inline dropdowns embed in a caller-managed layout (label hidden), so only the standalone + -- form owns a fixed slot height; inline keeps whatever height its host passes. + if not opts.inline then + container.preferredHeight = GUI.RowHeight.dropdown -- factory-owned slot (see GUI.RowHeight) + container.rowKind = "dropdown" + container.fixedRowHeight = true + end + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + -- Expose label so helpers like AddSectionNewBadge can anchor a badge to it. + container.label = lbl + if opts.inline then + lbl:Hide() + end + + -- Add override indicators if dbKey is provided (for auto profiles) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + dbTable[dbKey] = globalVal + if container.UpdateText then + container:UpdateText() + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, lbl, dbKey, onReset, 6, options, dbTable) + end + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + if opts.inline then + -- Fill the container so the caller's SetSize(w, h) controls the opener + -- size + vertical centering (inline callers add their own left label and + -- size the container to match the surrounding row, e.g. 140x18 / 110x16). + btn:SetAllPoints(container) + else + local dY = SnapLen(btn, -16) or -16 + btn:SetPoint("TOPLEFT", 0, dY) + btn:SetPoint("TOPRIGHT", 0, dY) + btn:SetHeight(SnapLen(btn, 24) or 24) + end + CreateElementBackdrop(btn) + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 8, 0) + btn.Text:SetPoint("RIGHT", -20, 0) + btn.Text:SetJustifyH("LEFT") + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Arrow indicator + local arrow = btn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -8, 0) + arrow:SetSize(12, 12) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- SetDisplayOverride: a fixed opener caption that wins over the selected + -- option's text (e.g. a disabled dropdown explaining WHY it's disabled, + -- like the Aura Designer spec dropdown's "shared across specs" state). + -- nil clears the override and restores the selected option's text. + local displayOverride + local function UpdateText() + if displayOverride then + btn.Text:SetText(displayOverride) + return + end + if customGet or (dbTable and dbKey) then + local val = customGet and customGet() or dbTable[dbKey] + local displayVal = options[val] + -- Handle table format: {value = X, text = "text"} or {text = "text"} + local optColor + if type(displayVal) == "table" then + optColor = displayVal.color + displayVal = displayVal.text or displayVal.label or tostring(val) + end + btn.Text:SetText(displayVal or tostring(val) or L["Select..."]) + -- Selected label mirrors its option row's colour when the option + -- carries one (e.g. class-coloured specs); plain options reset to + -- the standard text colour. Skipped while disabled so the + -- grey-when-disabled treatment from SetEnabled stays intact. + if btn:IsEnabled() then + local tc = optColor or C_TEXT + btn.Text:SetTextColor(tc.r, tc.g, tc.b) + end + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + end + container.UpdateText = UpdateText -- Expose for reset + container.SetDisplayOverride = function(self, text) + displayOverride = text + UpdateText() + end + + -- Menu frame + -- Menus hang from the opener's LEFT edge by default, so a wider-than-opener + -- menu spills rightward. opts.menuAlign = "RIGHT" pins the menu's TOPRIGHT + -- to the opener's BOTTOMRIGHT instead (surplus width grows leftward) — for + -- openers sitting near a right edge, e.g. the Aura Designer spec dropdown. + local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") + if opts.menuAlign == "RIGHT" then + menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) + else + menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) + end + menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menuFrame) + menuFrame:SetClampedToScreen(true) + CreateElementBackdrop(menuFrame) + menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) + menuFrame:Hide() + + -- Searchable menus (opts.searchable): a search box pinned above a scrollable + -- item list, mirroring the font/sound dropdowns. Option sets may also carry + -- non-clickable group-header rows — `_order` entries whose option value is + -- { header = true, text = ..., color = ... } (e.g. class-coloured spec + -- groups). While filtering, a header only stays visible if at least one of + -- its options matches. + local searchable = opts.searchable + local ITEM_HEIGHT = 22 + local SEARCH_HEIGHT = 26 + local MAX_VISIBLE = opts.maxVisible or 12 + local searchBox, scrollFrame, scrollChild, searchPlaceholder + if searchable then + searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") + searchBox:SetPoint("TOPLEFT", 4, -4) + searchBox:SetPoint("TOPRIGHT", -4, -4) + searchBox:SetHeight(22) + searchBox:SetAutoFocus(false) + searchBox:SetFontObject(DFFontHighlightSmall) + searchBox:SetTextInsets(24, 8, 0, 0) + CreateElementBackdrop(searchBox) + searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) + + local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") + searchIcon:SetPoint("LEFT", 6, 0) + searchIcon:SetSize(12, 12) + searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") + searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + searchPlaceholder:SetPoint("LEFT", 24, 0) + searchPlaceholder:SetText(L["Search..."]) + searchPlaceholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + + searchBox:SetScript("OnEditFocusGained", function() searchPlaceholder:Hide() end) + searchBox:SetScript("OnEditFocusLost", function() + if searchBox:GetText() == "" then searchPlaceholder:Show() end + end) + searchBox:SetScript("OnEscapePressed", function() menuFrame:Hide() end) + + scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) + scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) + scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetWidth(200) -- resized to the menu width on each rebuild + scrollFrame:SetScrollChild(scrollChild) + StyleScrollBar(scrollFrame) + end + + -- Clear tracking when hidden + menuFrame:SetScript("OnHide", function() + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil + end + if searchBox then + searchBox:SetText("") + searchBox:ClearFocus() + searchPlaceholder:Show() + end + end) + + local menuButtons = {} + local menuHeight = 0 + local sortedOptions = {} + + -- Build (or rebuild) the menu buttons from the current `options` upvalue. + -- Rows are POOLED (frames can't be garbage-collected) — rebuilds reuse + -- existing buttons and hide the surplus, so dynamic/searchable dropdowns + -- don't leak a row set per rebuild. Static callers build exactly once below. + local menuContentW = 0 -- widest item text; sizes the menu to fit long options + local function BuildMenuButtons(filterText) + for _, b in ipairs(menuButtons) do b:Hide() end + wipe(sortedOptions) + menuHeight = 0 + + -- Collect the ordered option list (header rows ride along) + local ordered = {} + -- Check for custom order array + if options._order then + -- Use specified order + for _, k in ipairs(options._order) do + local v = options[k] + if v then + -- Handle both formats: KEY = "text" or KEY = {value=, text=, color=, header=} + local isTable = type(v) == "table" + table.insert(ordered, { + key = k, + value = isTable and (v.text or v.label or tostring(k)) or v, + color = isTable and v.color or nil, + header = isTable and v.header or nil, + }) + end + end + else + -- Default: sort alphabetically by display value + for k, v in pairs(options) do + local isTable = type(v) == "table" + table.insert(ordered, { + key = k, + value = isTable and (v.text or v.label or tostring(k)) or v, + color = isTable and v.color or nil, + header = isTable and v.header or nil, + }) + end + table.sort(ordered, function(a, b) + local aVal = type(a.value) == "string" and a.value or tostring(a.key) + local bVal = type(b.value) == "string" and b.value or tostring(b.key) + return aVal < bVal + end) + end + + -- Apply the search filter (searchable menus): match option display text; + -- keep a group header only when one of its options survives. + local filter = filterText and filterText ~= "" and filterText:lower() or nil + if filter then + local pendingHeader + for _, opt in ipairs(ordered) do + if opt.header then + pendingHeader = opt + else + local txt = type(opt.value) == "string" and opt.value:lower() or tostring(opt.key):lower() + if txt:find(filter, 1, true) then + if pendingHeader then + table.insert(sortedOptions, pendingHeader) + pendingHeader = nil + end + table.insert(sortedOptions, opt) + end + end + end + else + for _, opt in ipairs(ordered) do + table.insert(sortedOptions, opt) + end + end + + local itemParent = scrollChild or menuFrame + local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) + local selColor = accentColor or GetThemeColor() + -- Once a group header has appeared, subsequent option rows indent under + -- it (menus without headers keep the flat 8px inset). + local seenHeader = false + + for i, opt in ipairs(sortedOptions) do + local menuBtn = menuButtons[i] + if not menuBtn then + menuBtn = CreateFrame("Button", nil, itemParent) + menuBtn:SetPoint("TOPLEFT", 2, -2 - (i - 1) * ITEM_HEIGHT) + menuBtn:SetPoint("TOPRIGHT", -2, -2 - (i - 1) * ITEM_HEIGHT) + menuBtn:SetHeight(ITEM_HEIGHT) + + menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + menuBtn.Text:SetPoint("LEFT", 8, 0) + + -- Subtle separator above group-header rows (hidden on option rows + -- and on a header that is the first visible row) + menuBtn.Sep = menuBtn:CreateTexture(nil, "ARTWORK") + menuBtn.Sep:SetHeight(1) + menuBtn.Sep:SetPoint("TOPLEFT", 4, 1) + menuBtn.Sep:SetPoint("TOPRIGHT", -4, 1) + menuBtn.Sep:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.6) + menuBtn.Sep:Hide() + + menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") + menuBtn.Highlight:SetAllPoints() + + menuBtn:SetScript("OnClick", function(self) + local optKey = self.optKey + -- Runtime override protection: redirect to baseline, skip refresh + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, optKey) then + UpdateText() + menuFrame:Hide() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(optKey) end + return + end + + if customSet then + customSet(optKey) + else + dbTable[dbKey] = optKey + end + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, customGet and customGet() or optKey) + end + + UpdateText() + menuFrame:Hide() + DF:UpdateAll() + if callback then callback() end + if parent.RefreshStates then parent:RefreshStates() end + end) + + menuButtons[i] = menuBtn + end + + menuBtn.optKey = opt.key + menuBtn.Text:SetText(opt.value) + menuBtn.Highlight:SetColorTexture(selColor.r, selColor.g, selColor.b, 0.3) + if opt.header then + -- Non-clickable group label (no mouse ⇒ no hover highlight). + -- Heading treatment: small uppercase label in the group colour, + -- separator line above (except when it's the first row), and + -- the option rows beneath it are indented — so headers read as + -- section labels rather than selectable entries. + menuBtn:EnableMouse(false) + local hc = opt.color or C_TEXT_DIM + menuBtn.Text:SetTextColor(hc.r, hc.g, hc.b) + -- SetTextScale (not SetSettingsFont) so the pooled row resets + -- cleanly when it's reused as a regular option row. + menuBtn.Text:SetTextScale(0.85) + if type(opt.value) == "string" then + menuBtn.Text:SetText(opt.value:upper()) + end + menuBtn.Text:ClearAllPoints() + menuBtn.Text:SetPoint("LEFT", 8, 0) + menuBtn.Sep:SetShown(i > 1) + seenHeader = true + else + menuBtn.Text:SetTextScale(1) + menuBtn.Text:ClearAllPoints() + menuBtn.Text:SetPoint("LEFT", seenHeader and 16 or 8, 0) + menuBtn.Sep:Hide() + menuBtn:EnableMouse(true) + if opt.color then + -- per-option colour (e.g. class-coloured spec list) always wins + menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) + elseif currentVal == opt.key then + menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + menuBtn:Show() + menuHeight = menuHeight + ITEM_HEIGHT + end + + -- Width fits the widest item so long options aren't clipped by a narrow + -- opener (refined to max(opener, content) on open, once btn is sized). + menuContentW = 0 + for i = 1, #sortedOptions do + menuContentW = math.max(menuContentW, menuButtons[i].Text:GetStringWidth() or 0) + end + if searchable then + local visible = math.min(#sortedOptions, MAX_VISIBLE) + menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) + menuFrame:SetHeight(visible * ITEM_HEIGHT + SEARCH_HEIGHT + 8) + scrollChild:SetWidth(menuFrame:GetWidth() - 24) + scrollChild:SetHeight(menuHeight + 4) + scrollFrame:SetVerticalScroll(0) + else + menuFrame:SetWidth(menuContentW + 24) + menuFrame:SetHeight(menuHeight + 4) + end + end + + BuildMenuButtons() + + if searchBox then + searchBox:SetScript("OnTextChanged", function(self) + if menuFrame:IsShown() then BuildMenuButtons(self:GetText()) end + end) + end + + -- Allow dynamic dropdowns to swap their option set and regenerate buttons. + container.RebuildOptions = function(_, newOptions) + if newOptions then options = newOptions end + BuildMenuButtons(searchBox and searchBox:GetText() or nil) + UpdateText() + end + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + btn:SetScript("OnClick", function(self) + if menuFrame:IsShown() then + menuFrame:Hide() + S.currentOpenDropdown = nil + else + -- Close any other open dropdown first + CloseOpenDropdown() + -- Dynamic dropdowns regenerate their option list each open. + if optionsFunc then + container:RebuildOptions(optionsFunc()) + elseif searchable then + -- Searchable menus reopen unfiltered (search cleared on hide) + BuildMenuButtons() + else + -- Static menus: refresh selected-value colouring on the pooled rows + local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) + local selColor = accentColor or GetThemeColor() + for i, opt in ipairs(sortedOptions) do + local menuBtn = menuButtons[i] + if menuBtn and not opt.header then + if opt.color then + -- per-option colour (e.g. class-coloured spec list) always wins + menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) + elseif currentVal == opt.key then + menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + end + end + if searchable then + menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) + scrollChild:SetWidth(menuFrame:GetWidth() - 24) + else + menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 24)) + end + menuFrame:Show() + S.currentOpenDropdown = menuFrame + if searchBox then searchBox:SetFocus() end + end + end) + + btn:SetScript("OnShow", UpdateText) + UpdateText() + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so its preview/value (texture swatch, font preview, + -- selected text) greys with the label rather than staying full-bright. + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + UpdateText() -- restore per-option colour on the selected label + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The label + -- sits at the container's TOPLEFT, above the opener, so it is well clear of + -- the menu you are about to click. + GUI:AttachTooltip(container, label, lbl) + + -- SEARCH: Register this setting + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, options, nil, callback) + end + + return container +end + +-- ============================================================ +-- OUTLINE + SHADOW CONTROLS +-- A flag dropdown and a shadow checkbox that both bind to a single stored +-- outline value (see DF:OutlineFlag / OutlineHasShadow / ComposeOutline in +-- Config.lua). Shadow is decoupled from the outline flag so any flag can be +-- combined with a drop shadow, mirroring Grid2's font options. +-- ============================================================ + +local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "MONOCHROME, OUTLINE", "MONOCHROME, THICKOUTLINE" } + +function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) + local options = { + NONE = L["None"], + OUTLINE = L["Outline"], + THICKOUTLINE = L["Thick Outline"], + MONOCHROME = L["Monochrome"], + ["MONOCHROME, OUTLINE"] = L["Monochrome Outline"], + ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], + _order = OUTLINE_FLAG_ORDER, + } + local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end + local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end + return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) +end + +function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) + local get = function() return DF:OutlineHasShadow(dbTable[dbKey]) end + local set = function(val) dbTable[dbKey] = DF:ComposeOutline(DF:OutlineFlag(dbTable[dbKey]), val) end + return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) +end + +-- ============================================================ +-- UNIFIED BORDER CONTROL SET +-- Drops the canonical Show / Style / Texture / Size / Colour controls plus +-- whichever optional Phase B controls the consumer opts into (offset, inset, +-- blendMode, gradient, shadow). Saved-variable keys are built from a single +-- camelCase `prefix` (e.g. "defensiveIcon" → "defensiveIconBorderSize"), so +-- consumers add one call instead of hand-rolling ~6-15 widgets each. +-- +-- Each opts.include flag is per-element: "tailor-made to what makes logical +-- sense" — the API exposes everything, but consumers opt in only to what fits +-- their element. Returns a table of widget references so the caller can add +-- per-element extras (dispel-type colour, pulsate, etc.) afterwards. +-- +-- opts = { +-- parent = the panel widget (e.g. self.child) — same first arg the +-- underlying CreateCheckbox/Slider/etc. take +-- include = { offset=, inset=, blendMode=, gradient=, shadow=, +-- classColor=, roleColor=, colorByTime=, colorByType= } +-- fullUpdate = callback for full re-render (drop / value-set) +-- lightUpdate = callback for slider-drag (size, offsets, shadow sliders) +-- lightColors = callback for live colour-picker preview +-- refreshStates = optional hook fired when Show/Gradient/Shadow toggles +-- change visibility of other widgets +-- disableWhen = optional predicate fn(db) → bool. When true, EVERY widget +-- (including the Show toggle itself) GREYS OUT in place. This +-- is what a consumer whose border sits under a feature toggle +-- wants — the addon-wide rule is that a deactivated control +-- greys where it is, so the page doesn't reflow and you can +-- still see what turning the feature on would give you. +-- hideWhen = optional predicate fn(db) → bool. When true, EVERY widget +-- (including the Show toggle itself) HIDES. Reserve this for a +-- gate that changes WHAT the page offers — a variant switch +-- like Pinned Frames' per-set border override, where the +-- controls belong to a mode you are not in. A plain on/off +-- feature toggle is disableWhen, not this. +-- sizeMin / sizeMax / sizeStep = slider range overrides +-- offsetMin / offsetMax / offsetStep +-- } +-- ============================================================ +-- CreateAnimationControls — the Border Animation control set +-- (Type dropdown + every per-effect tunable), extracted so the base +-- Border Animation panel (CreateBorderControls / include.animate) AND +-- Aura Designer's Expiring Animation override render an IDENTICAL set of +-- widgets from ONE source. Add or remove an effect / tunable here and both +-- panels update together — no drift. +-- +-- group = SettingsGroup the widgets are added to +-- dbTable = db / proxy the widgets read & write +-- animPrefix = key namespace; widgets target dbTable[animPrefix .. suffix] +-- (base border: "BorderAnimation"; AD expiring: +-- "ExpiringAnimation") +-- opts: +-- parent = frame parent for the widgets +-- fullUpdate = heavy refresh callback (dropdown / slider-release / colour) +-- lightUpdate = light refresh callback (slider-drag) +-- lightColors = live colour-picker preview callback (needed for AD's +-- proxy, whose sub-table colour writes skip __newindex) +-- typeLabel = label for the Type dropdown +-- hideExtra = optional predicate; when true the WHOLE block hides +-- (the border panel folds the block under Show Border; +-- the always-visible Expiring override omits it) +-- onTypeChange = runs after the Type dropdown changes (re-layout / reflow) +-- perfBanner = show the per-border FPS warning banner (default true) +-- Returns the widget table (animationType, animationColor, … ) so the caller +-- can merge the handles into its own control table. +-- ============================================================ +function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) + opts = opts or {} + local parent = opts.parent + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local typeLabel = opts.typeLabel or L["Border Animation"] + local excludeTypes = opts.excludeTypes -- optional set of animation-type keys to omit from the dropdown + local hideExtra = opts.hideExtra + local onTypeChange = opts.onTypeChange or function() end + local showPerfBanner = opts.perfBanner ~= false + + local function aKey(suffix) return animPrefix .. suffix end + local animTypeKey = aKey("Type") + local function animType() return dbTable[animTypeKey] or "NONE" end + local function extraOff() return (hideExtra and hideExtra()) or false end + local function animOff() return extraOff() or animType() == "NONE" end + + -- Sets of effect types each tunable applies to (truthiness on a + -- string-keyed set). Mirrors the per-effect parameter map — keep in + -- sync with StartAnimation's branches in Frames/Border.lua. + -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash + -- thickness, Inset = dash inset. + local hasFrequency = { DF_PULSATE=1, + DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } + local hasParticles = { DF_ORBIT=1, DF_PIXEL=1 } + -- CORNERS_ONLY is hidden from the type dropdown below, but keep its param + -- entries so an indicator that still carries a saved CORNERS_ONLY value shows + -- the right controls. + local hasThickness = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_PIXEL=1 } + -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which + -- modulates the border's own edges and has no separate animRect). + local hasPositioning = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } + -- Scale slider = sparkle size (DF Chase). + local hasScale = { DF_ORBIT=1 } + -- Length slider = bar length (DF Pixel's chasing bars). + local hasLength = { DF_PIXEL=1 } + local cornersOnly = { CORNERS_ONLY=1 } + local function hideUnless(set) + return function() + if animOff() then return true end + return not set[animType()] + end + end + + local w = {} + + -- All DF-owned border effects (no external glow library). The "DF " labels + -- are kept from when they sat alongside the retired LCG glows. + local animTypeOptions = { + NONE = L["None"], + DF_PULSATE = L["DF Pulsate"], + DF_ORBIT = L["DF Chase"], + DF_DASH = L["DF Dash"], + DF_FLASH = L["DF Flash"], + DF_PIXEL = L["DF Pixel"], + DF_PROC = L["DF Proc"], + BLINK = L["Blink"], + -- None first (the "off" option), then alphabetical by label. CORNERS_ONLY + -- is intentionally absent — it's kept in the engine (an existing saved + -- value still renders) but no longer offered as a pickable animation. + _order = { "NONE", "BLINK", "DF_ORBIT", + "DF_DASH", "DF_FLASH", "DF_PIXEL", "DF_PROC", "DF_PULSATE" }, + } + -- Optional caller filter: drop any excluded type from both the value map and + -- the display order (e.g. the Aura Designer border offers only the taint-safe, + -- overlay-recoverable animations — no LCG glows). + if excludeTypes then + for k in pairs(excludeTypes) do animTypeOptions[k] = nil end + local filteredOrder = {} + for _, k in ipairs(animTypeOptions._order) do + if not excludeTypes[k] then filteredOrder[#filteredOrder + 1] = k end + end + animTypeOptions._order = filteredOrder + end + w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, + animTypeOptions, + dbTable, animTypeKey, onTypeChange), 55) + -- Type dropdown respects only the extra gate (e.g. Show Border). With no + -- extra gate (Expiring override) it's always visible. + w.animationType.hideOn = hideExtra or function() return false end + + -- Perf warning: animations run an OnUpdate (or LCG internal animation) + -- per active border, which adds up in 20-30 player raids. + if showPerfBanner then + -- staticHeight ONLY where the host reflows widget WIDTHS on every layout + -- pass — i.e. the Aura Designer indicator card (its parent carries + -- dfAD_ReflowWidgets). There a self-sizing banner feeds a SetHeight -> + -- OnSizeChanged -> relayout -> SetWidth loop that drops FPS, so we predict + -- a fixed height instead. On normal settings pages the host lays out once, + -- so the banner MUST self-size to its wrapped text: a fixed height + -- overflows (text spills past the box) on narrow windows until a manual + -- drag forces a relayout. + local reflowingHost = parent and parent.dfAD_ReflowWidgets ~= nil + local perfBanner = GUI:CreateInfoBanner(parent, { + tone = "caution", + text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], + staticHeight = reflowingHost or nil, + minHeight = 56, + }) + w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) + w.animationPerfBanner.hideOn = animOff + end + + -- Animation colour applies to every effect except DF_PULSATE (which + -- modulates the border's own edge alpha — no separate colour). lightColors + -- is threaded through so AD's proxy gets live preview while dragging. + w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Color"], + dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.animationColor.hideOn = function() + return animOff() or animType() == "DF_PULSATE" + end + + -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. + -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), + -- and the OnUpdate effects fall back to a sensible default period at 0. + w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], + 0, 4, 0.05, dbTable, aKey("Frequency"), + fullUpdate, lightUpdate, true), 55) + w.animationFrequency.hideOn = hideUnless(hasFrequency) + -- ⚠ This slider genuinely means different things per effect (see the comment + -- above), which is exactly why it needs saying out loud — nobody discovers + -- "0 = hold still" by dragging. + w.animationFrequency.tooltip = L["How fast the effect runs. On DF Dash this is how quickly the dashes march around the edge, and 0 holds them still. On the others it is the pulse rate, where 0 means the effect's own default speed."] + + w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], + 1, 16, 1, dbTable, aKey("Particles"), + fullUpdate, lightUpdate, true), 55) + w.animationParticles.hideOn = hideUnless(hasParticles) + w.animationParticles.tooltip = L["How many separate lights travel around the border. More reads as busier and costs a little more to draw."] + + w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], + 1, 30, 1, dbTable, aKey("Length"), + fullUpdate, lightUpdate, true), 55) + w.animationLength.hideOn = hideUnless(hasLength) + w.animationLength.tooltip = L["How long each moving segment is. Short values read as darting sparks, long ones as a sweeping tail."] + + w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], + 1, 12, 1, dbTable, aKey("Thickness"), + fullUpdate, lightUpdate, true), 55) + w.animationThickness.hideOn = hideUnless(hasThickness) + w.animationThickness.tooltip = L["How heavy the moving effect is. Separate from Border Thickness — the animation draws on its own layer, so it can be thicker or thinner than the border underneath."] + + w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], + 0.5, 3, 0.05, dbTable, aKey("Scale"), + fullUpdate, lightUpdate, true), 55) + w.animationScale.hideOn = hideUnless(hasScale) + + w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], + -50, 50, 1, dbTable, aKey("Inset"), + fullUpdate, lightUpdate, true), 55) + w.animationInset.hideOn = hideUnless(hasPositioning) + w.animationInset.tooltip = L["Moves the effect in or out from the edge, independently of the border. Push it outward to make a glow spill past the frame."] + + w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], + -50, 50, 1, dbTable, aKey("OffsetX"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetX.hideOn = hideUnless(hasPositioning) + + w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], + -50, 50, 1, dbTable, aKey("OffsetY"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetY.hideOn = hideUnless(hasPositioning) + + -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). + w.animationHideIntro = group:AddWidget(GUI:CreateCheckbox(parent, L["Hide Intro Flash"], + dbTable, aKey("ProcStart"), fullUpdate), 30) + w.animationHideIntro.hideOn = hideUnless({ DF_FLASH = 1, DF_PROC = 1 }) + w.animationHideIntro.tooltip = L["These effects open with a one-off burst before settling into their loop. Turn this on to skip the burst and go straight to the loop."] + + w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], + 2, 40, 1, dbTable, aKey("CornerLength"), + fullUpdate, lightUpdate, true), 55) + w.animationCornerLength.hideOn = hideUnless(cornersOnly) + w.animationCornerLength.tooltip = L["How far the effect runs along each edge from the corner before stopping. Small values leave four short brackets instead of a full outline."] + + return w +end + +-- ============================================================ +function GUI:CreateBorderControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local refreshStates = opts.refreshStates + local hideWhen = opts.hideWhen + local disableWhen = opts.disableWhen + + local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 + local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 + + local function key(suffix) return prefix .. suffix end + local showKey = key("ShowBorder") + -- The Show toggle only respects the parent-level hideWhen. Everything + -- else respects hideWhen OR the Show toggle being off. + -- + -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is + -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` + -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, + -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. + -- For consumers with a different dbTable — notably Aura Designer's + -- per-aura proxy — this is the only way the visibility predicates see + -- the actual border state (e.g. proxy.BorderStyle, not the unrelated + -- DF.db.party.BorderStyle which doesn't exist). + local function hideShow() return hideWhen and hideWhen(dbTable) or false end + -- Show Border OFF no longer HIDES the border controls — they stay visible and + -- GREY OUT (disableOn = borderOff, applied by the loop at the end of this + -- function) so the panel previews them. `hideOff` now means "hidden by the + -- parent/variant gate only" (whatever the consumer passes via hideWhen); the + -- name is kept so the existing `.hideOn = hideOff` references read unchanged. + local function hideOff() return hideShow() end + local function borderOff() return dbTable[showKey] == false end + + local w = {} + + -- opts.noShowToggle: suppress the built-in "Show Border" checkbox for + -- consumers that gate the whole border on an external toggle (e.g. the + -- Targeted Spells "Highlight Important Spells" master). With the checkbox + -- gone, showKey stays nil so hideOff() reduces to hideShow() — the toolkit + -- shows/hides purely on the external hideWhen. + if not opts.noShowToggle then + w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.show.hideOn = hideShow + end + + -- Slider label reads "Border Thickness" (more meaningful than "Size") but + -- the underlying db key stays `BorderSize` and spec.size in the + -- backend stays the same — purely a user-facing rename, no migration. + w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, + dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) + w.size.hideOn = hideOff + + -- Gradient is a STYLE, not a separate toggle. When the consumer opts into + -- gradient via include.gradient, we expose GRADIENT as a third dropdown + -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. + local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], + _order = { "SOLID", "TEXTURE" } } + if include.gradient then + styleOptions.GRADIENT = L["Gradient"] + -- Insert GRADIENT between SOLID and TEXTURE so the order reads + -- "simple colour → two colours → custom texture" in the dropdown. + styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } + end + w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], + styleOptions, dbTable, key("BorderStyle"), function() + -- Match the frame border: pick the first LSM border when switching + -- to Texture without one configured. + if dbTable[key("BorderStyle")] == "TEXTURE" then + local list = DF.GetBorderList and DF:GetBorderList() or nil + local t = dbTable[key("BorderTexture")] + if list and (not t or t == "" or t == "SOLID") then + dbTable[key("BorderTexture")] = next(list) + end + end + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.style.hideOn = hideOff + + -- isGradient is declared up here so the Style-dependent widget cluster + -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) + -- can sit immediately below the Style dropdown — the consequence of the + -- user's style choice reads top-to-bottom without scrolling past + -- unrelated inset / offset / blend controls first. + local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end + + w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], + DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) + w.texture.hideOn = function() + return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" + end + + -- Gradient pickers — only visible under Style = GRADIENT. Grouped here + -- (between Texture and the Colour Source dropdown) so all style-dependent + -- widgets sit directly under the Style dropdown that controls them. + -- The standalone "Border Gradient" checkbox was removed when Style + -- absorbed it; Style is now the single source of truth so it's not + -- possible to pick "Solid + Class Color" then have a Gradient checkbox + -- stomp the class colour (the previous UX bug). Legacy + -- `BorderGradientEnabled = true` profiles are migrated to + -- `BorderStyle = "GRADIENT"` on db load. + if include.gradient then + local function gradHide() return hideOff() or not isGradient() end + + w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Color"], + dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) + w.gradientStart.hideOn = gradHide + w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Color"], + dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) + w.gradientEnd.hideOn = gradHide + w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], + { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, + dbTable, key("BorderGradientDirection"), fullUpdate), 55) + w.gradientDirection.hideOn = gradHide + end + + -- Colour Source dropdown sits ABOVE the colour picker so the relationship + -- "source → resulting colour" reads top-to-bottom in the panel. The + -- options table is built dynamically: Static is always present; Class + -- and Role are added if the consumer opted in via the matching include. + -- Hidden in GRADIENT style — gradient owns its own colours, no resolver + -- chain applies (see Border:BuildSpec). + local sourceKey = key("BorderColorSource") + local hasSourceDropdown = include.classColor or include.roleColor + if hasSourceDropdown then + local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } + if include.classColor then + sourceOptions.CLASS = L["Class"] + sourceOptions._order[#sourceOptions._order + 1] = "CLASS" + end + if include.roleColor then + sourceOptions.ROLE = L["Role"] + sourceOptions._order[#sourceOptions._order + 1] = "ROLE" + end + -- Default the source from the legacy boolean keys when first opened. + if dbTable[sourceKey] == nil then + if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" + elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" + else dbTable[sourceKey] = "STATIC" end + end + w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], + sourceOptions, dbTable, sourceKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.colorSource.hideOn = function() return hideOff() or isGradient() end + w.colorSource.tooltip = L["Where the border colour comes from. Static uses the colour below; Class and Role read it from the unit, so the border tells you who you are looking at without reading the name."] + end + + -- Static colour picker — only visible when source is STATIC (or when the + -- consumer didn't enable any resolver at all, so source doesn't exist). + -- Hidden in GRADIENT style (gradient uses its own start/end pickers). + w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), + true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.color.hideOn = function() + if hideOff() or isGradient() then return true end + if hasSourceDropdown then + local src = dbTable[sourceKey] or "STATIC" + return src ~= "STATIC" + end + return false + end + + -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes + -- the SAME alpha component the colour picker exposes + -- (BorderColor.a), so the slider is just a convenient handle for + -- the picker's alpha bar — no separate alpha key to migrate or keep in + -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the + -- two gradient pickers each carry their own alpha, and a single slider + -- has no obvious meaning). + if include.alpha then + -- Ensure the underlying colour table has an alpha component so the + -- slider doesn't read nil on first open. The picker also seeds .a but + -- we don't depend on widget-creation order. + local c = dbTable[key("BorderColor")] + if type(c) ~= "table" then + c = { r = 0, g = 0, b = 0, a = 1 } + dbTable[key("BorderColor")] = c + end + if c.a == nil then c.a = 1 end + + -- Read-time nil-guard: these closures fire on the slider's OnShow at + -- arbitrary later times (tab/page re-show, mode switch), NOT just at + -- creation. The seed above only guarantees the table exists NOW — a proxy + -- dbTable can resolve BorderColor to nil later (e.g. re-showing the AD page + -- for a mode whose config doesn't surface the key), so re-read and guard + -- each call instead of assuming the table is still there. + w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, + nil, nil, fullUpdate, lightColors or lightUpdate, true, + function() + local bc = dbTable[key("BorderColor")] + return (bc and bc.a) or 1 + end, + function(v) + local bc = dbTable[key("BorderColor")] + if bc then bc.a = v end + end), 55) + w.alpha.hideOn = function() return hideOff() or isGradient() end + end + + if include.inset then + w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, + dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) + w.inset.hideOn = hideOff + -- Thickness / Inset / Offset are three similar-sounding sliders that do + -- different things; the tooltip lives here because Inset is the one + -- nobody guesses. + w.inset.tooltip = L["Pulls the border inward (positive) or pushes it outward (negative) from the edge. Thickness is how heavy the line is, Inset is how far in it sits, Offset slides the whole border sideways."] + end + + if include.offset then + w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, + dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) + w.offsetX.hideOn = hideOff + w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, + dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) + w.offsetY.hideOn = hideOff + -- No tooltip on Offset X/Y, deliberately, and the same goes for every + -- other Offset slider in the addon (~60 of them): an offset is a well + -- understood control and a tooltip restating it is noise. Inset is the + -- one that needs explaining, so the Thickness / Inset / Offset + -- distinction is spelled out THERE, once. Krathe's call, 2026-07-27 — + -- these two briefly had tooltips and Border Shadow's offsets did not, + -- which is the inconsistency that prompted it. + end + + if include.blendMode then + w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], + { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"], DISABLE = L["Disable"] }, + dbTable, key("BorderBlendMode"), fullUpdate), 55) + w.blendMode.hideOn = hideOff + w.blendMode.tooltip = L["How the border colour mixes with whatever is behind it. Blend is normal. Add brightens and is what makes a colour glow. Modulate darkens. Disable ignores opacity entirely and draws the colour flat."] + end + + if include.shadow then + local shadowOnKey = key("BorderShadowEnabled") + w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.shadowEnabled.hideOn = hideOff + -- Border Shadow OFF greys (not hides) its sub-controls — a nested boolean + -- toggle, same grey-everything rule. The end-of-function loop OR-composes + -- borderOff, so these also grey when Show Border is off. + local function shadowOff() return dbTable[shadowOnKey] == false end + + w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Color"], + dbTable, key("BorderShadowColor"), true, fullUpdate), 35) + w.shadowColor.hideOn = hideOff + w.shadowColor.disableOn = shadowOff + w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, + dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) + w.shadowSize.hideOn = hideOff + w.shadowSize.disableOn = shadowOff + w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, + dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetX.hideOn = hideOff + w.shadowOffsetX.disableOn = shadowOff + w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, + dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetY.hideOn = hideOff + w.shadowOffsetY.disableOn = shadowOff + end + + -- ===== Animation (Stage 3) ===== + -- include.animate drops the full Border Animation control set (Type + -- dropdown + per-effect tunables, each with a hideOn keyed to the effect + -- it applies to). Built from the shared GUI:CreateAnimationControls so the + -- base panel and AD's Expiring override never drift. The whole block folds + -- under Show Border via hideExtra = hideOff. Widget handles are merged back + -- onto `w` so existing references (w.animationType, …) are preserved. + if include.animate then + local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { + parent = parent, + fullUpdate = fullUpdate, + lightUpdate = lightUpdate, + lightColors = lightColors, + typeLabel = L["Border Animation"], + -- Optional caller filter, forwarded from the CreateBorderControls call + -- site (e.g. the Aura Designer border restricts to overlay-recoverable + -- animation types). nil for every other caller → full type list. + excludeTypes = opts.animExcludeTypes, + hideExtra = hideOff, + onTypeChange = function() + if refreshStates then refreshStates() end + fullUpdate() + end, + }) + for k, v in pairs(aw) do w[k] = v end + end + + -- ===== Colour resolver toggles (Stage 2) ===== + -- These flip BorderColor's source from the static picker to a per-unit / + -- per-aura / per-tick computation. BuildSpec applies them in priority + -- order (type > time > class > role > static) when the consumer passes + -- ctx to BuildSpec. The static colour picker still controls the fallback + -- (when ctx is missing or the resolver yields nil). + + -- (Colour Source dropdown + Static colour picker + Alpha slider are wired + -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the + -- relationship "source → colour" reads top-to-bottom in the panel.) + + if include.colorByTime then + w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) + w.colorByTime.hideOn = hideOff + -- The actual colour curve picker is consumer-specific (e.g. AD's + -- existing expiring colour curve) and is added by the consumer + -- alongside this checkbox. + end + + if include.colorByType then + w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) + w.colorByType.hideOn = hideOff + end + + -- Two independent greys, both composed on top of whatever disableOn a control + -- already carries (e.g. the shadow sub-controls), and both leaving the + -- variant hideOn untouched: + -- disableWhen — the CONSUMER's gate: the feature this border belongs to is + -- switched off. Applies to EVERY widget including the Show Border + -- checkbox, since with the feature off there is nothing for it to show. + -- borderOff — Show Border itself is off. Applies to everything EXCEPT the + -- Show Border checkbox, which has to stay clickable to turn it back on. + -- RefreshChildStates applies disableOn to group children, and CreateCheckbox + -- auto-refreshes on toggle, so both greys update live. + for k, widget in pairs(w) do + if type(widget) == "table" and widget.SetEnabled then + local prev = widget.disableOn + local isShow = (k == "show") + widget.disableOn = function(d) + if disableWhen and disableWhen(dbTable) then return true end + if not isShow and borderOff() then return true end + return (prev and prev(d)) or false + end + end + end + + return w +end + +-- ============================================================ +-- SHARED TEXT-STYLE CONTROLS (pairs with DF.TextStyle — the engine consumers +-- style FontStrings through). Mirrors CreateBorderControls: one builder, every +-- text block in the addon renders the same control flow instead of a hand-rolled +-- copy per page. Emits, in the pages' established order: +-- Font, Scale, Outline, Shadow, [Color], Anchor, Offset X/Y, [Justify H, Justify V] +-- Key convention: Font/Scale/Outline/Anchor/X/Y/JustifyH/JustifyV/Color. +-- +-- opts: +-- parent REQUIRED — the page scroll child (self.child) +-- include = { color = false, justify = true, anchor = true, offsets = true } +-- colorLabel colour picker label (default L["Text Color"]) +-- disableOn predicate applied to EVERY created widget (page-level gate) +-- hideOn predicate applied to EVERY created widget +-- colorDisableOn EXTRA disable gate for the colour picker only (e.g. colour-by-time on) +-- onChange full-update callback (dropdowns / colour commit) +-- onDrag lightweight slider-drag callback (also colour live-preview) +-- scaleMin/Max/Step, offsetMin/Max — slider ranges (defaults 0.5–2.0 ×0.05, ±150) +-- Returns the created widgets keyed { font, scale, outline, shadow, color, anchor, +-- offsetX, offsetY, justifyH, justifyV } so pages can attach extra gates. +-- ============================================================ +function GUI:CreateTextControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local onChange = opts.onChange + local onDrag = opts.onDrag or onChange + local L = DF.L + + local scaleMin, scaleMax, scaleStep = opts.scaleMin or 0.5, opts.scaleMax or 2.0, opts.scaleStep or 0.05 + local offMin, offMax = opts.offsetMin or -150, opts.offsetMax or 150 + + local function key(suffix) return prefix .. suffix end + local widgets = {} + + -- Apply the shared page gates to a widget, composing with any the widget factory set. + local function gate(w) + if opts.disableOn then + local prev = w.disableOn + w.disableOn = function(d) return (opts.disableOn(d) or (prev and prev(d))) and true or false end + end + if opts.hideOn then w.hideOn = opts.hideOn end + return w + end + + widgets.font = gate(group:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], dbTable, key("Font"), onChange), 55)) + widgets.scale = gate(group:AddWidget(GUI:CreateSlider(parent, L["Scale"], scaleMin, scaleMax, scaleStep, dbTable, key("Scale"), nil, onDrag, true), 55)) + widgets.outline = gate(group:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], dbTable, key("Outline"), onChange), 55)) + widgets.shadow = gate(group:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], dbTable, key("Outline"), onChange), 30)) + + if include.color then + widgets.color = gate(group:AddWidget(GUI:CreateColorPicker(parent, opts.colorLabel or L["Text Color"], dbTable, key("Color"), false, onChange, onDrag, true), 35)) + if opts.colorDisableOn then + local prev = widgets.color.disableOn + widgets.color.disableOn = function(d) return (opts.colorDisableOn(d) or (prev and prev(d))) and true or false end + end + end + + if include.anchor ~= false then + local anchorOptions = { + CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], + TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], + } + widgets.anchor = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], anchorOptions, dbTable, key("Anchor"), onChange), 55)) + -- Anchor vs Justify is the pair people get wrong: one places the text, + -- the other arranges it within its own box. Both say so, from their side. + widgets.anchor.tooltip = L["Which part of the element the text is pinned to. Offset X and Y then nudge it from there."] + end + + if include.offsets ~= false then + widgets.offsetX = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], offMin, offMax, 1, dbTable, key("X"), nil, onDrag, true), 55)) + widgets.offsetY = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], offMin, offMax, 1, dbTable, key("Y"), nil, onDrag, true), 55)) + end + + -- Justify is OPT-IN (include.justify = true). It's redundant with Anchor for short + -- single-token text on a small icon (duration/stacks) and boxing to justify TRUNCATES + -- wide text like "59m" — so the aura pages don't expose it. The DF.TextStyle engine + -- still honors JustifyH/JustifyV keys for a future wide/fixed-region consumer. + if include.justify then + local justifyHOptions = { [""] = L["Default"], LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } + local justifyVOptions = { [""] = L["Default"], TOP = L["Top"], MIDDLE = L["Middle"], BOTTOM = L["Bottom"] } + widgets.justifyH = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify H"], justifyHOptions, dbTable, key("JustifyH"), onChange), 55)) + widgets.justifyH.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] + widgets.justifyV = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify V"], justifyVOptions, dbTable, key("JustifyV"), onChange), 55)) + widgets.justifyV.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] + end + + return widgets +end + \ No newline at end of file From e39667d1f801f0a1d441b6adb1d9aae0b1b6aae3 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 17:54:42 +0100 Subject: [PATCH 05/34] AuraDesigner editor: hold mutable state on one table Behaviour-preserving prep for splitting this 9,000-line file. 38 mutable file-scope locals become fields on DF.AuraDesigner._uiState -- 471 reference sites rewritten, all mechanical. A file-scope `local` re-declared on the far side of a split becomes a SECOND variable, so the two halves silently stop sharing state: no error, just a tab that will not switch or a preview that never refreshes. Nearly every widget handle here was in that class -- mainFrame, tabBar, activeTab, framePreview, the four Build*Tab forward declarations. The rename was verified safe first: no inner scope anywhere in the file declares any of these names as a parameter or local, so no reference could be captured by mistake. Short names like `db` and `page` made that check worth doing rather than assuming. GUI and Adapter are deliberately NOT on the table. Both were assigned in BuildAuraDesignerPage, which made them mutable, but neither ever varies: every caller arrives as DF.GUI -> SetupGUIPages -> here, and Adapter was only ever assigned DF.AuraDesigner.Adapter. Resolving both at load instead leaves 412 further reference sites untouched. Side effect worth having: file-scope locals drop 196 -> 159. This file had already hit Lua's 200-per-chunk ceiling once -- there is a comment recording a constant deleted purely to reclaim a slot. --- AuraDesigner/UI/Options.lua | 843 ++++++++++++++++++------------------ 1 file changed, 429 insertions(+), 414 deletions(-) diff --git a/AuraDesigner/UI/Options.lua b/AuraDesigner/UI/Options.lua index 999804fa..a6b983ca 100644 --- a/AuraDesigner/UI/Options.lua +++ b/AuraDesigner/UI/Options.lua @@ -2,7 +2,7 @@ local addonName, DF = ... -- ============================================================ -- AURA DESIGNER - OPTIONS GUI --- Custom page layout: left content area + fixed 280px right panel +-- Custom S.page layout: left content area + fixed 280px right panel -- Called from Options/Options.lua via DF.BuildAuraDesignerPage() -- ============================================================ @@ -18,14 +18,31 @@ local RAID_CLASS_COLORS = RAID_CLASS_COLORS local LOCALIZED_CLASS_NAMES_MALE = LOCALIZED_CLASS_NAMES_MALE local L = DF.L --- Local references set during BuildAuraDesignerPage -local GUI -local page -local db -local Adapter +-- Mutable editor state. +-- These were file-scope locals. A `local` re-declared on the far side of a +-- file split becomes a SECOND variable, so the halves silently stop sharing +-- state -- no error, just a tab that will not switch or a preview that will +-- not refresh. One table keeps a split honest: each part reads the same +-- state via `local S = DF.AuraDesigner._uiState`. +-- GUI and Adapter are NOT here: both are load-time constants (DF.GUI and +-- DF.AuraDesigner.Adapter), so each part can simply re-declare them. +local S = {} +DF.AuraDesigner = DF.AuraDesigner or {} +DF.AuraDesigner._uiState = S + +-- Load-time constants. Both used to be assigned inside BuildAuraDesignerPage, +-- which made them mutable and so un-splittable. Neither ever actually varies: +-- every caller arrives as DF.GUI -> SetupGUIPages -> here, and Adapter was only +-- ever assigned DF.AuraDesigner.Adapter. Resolving them at load lets each split +-- part re-declare them as plain aliases -- which is why 412 of this file's +-- reference sites needed no change at all. +-- Both source files load earlier in the TOC, so these are populated here. +local GUI = DF.GUI +local Adapter = DF.AuraDesigner.Adapter +-- (S.page and S.db are set per build on the state table) -- State -local selectedSpec = nil -- Current spec key being viewed +S.selectedSpec = nil -- Current spec key being viewed -- Reusable color constants: reference the shared GUI palette (same numeric -- values, zero visual change) so they track any future palette change in @@ -713,7 +730,7 @@ local function GetAuraDesignerDB() adDB = DF:GetModeBaseAuraDesigner(mode) end -- Pre-migration / very-early fallback to the legacy inline config. - adDB = adDB or (db and db.auraDesigner) + adDB = adDB or (S.db and S.db.auraDesigner) if adDB and (not adDB._specScopedV1 or not adDB._specScopedV2) then MigrateToSpecScoped(adDB) end @@ -805,10 +822,10 @@ end -- to keep standard buff icons or let AD fully replace them. -- ============================================================ -local buffCoexistPopup +-- (S.buffCoexistPopup declared on the state table) local function ShowBuffCoexistPopup(onConfirm, onCancel) - if not buffCoexistPopup then + if not S.buffCoexistPopup then local f = CreateFrame("Frame", "DFADBuffPopup", UIParent, "BackdropTemplate") f:SetSize(420, 130) f:SetPoint("CENTER") @@ -862,10 +879,10 @@ local function ShowBuffCoexistPopup(onConfirm, onCancel) end end) - buffCoexistPopup = f + S.buffCoexistPopup = f end - local f = buffCoexistPopup + local f = S.buffCoexistPopup f._onCancel = onCancel f.keepBtn:SetScript("OnClick", function() @@ -1067,17 +1084,17 @@ end -- groups, migrations, the spec dropdown itself) deliberately do not. -- ============================================================ -local activeBuffTab = "my" -- "my" | "debuffs" | "other" +S.activeBuffTab = "my" -- "my" | "debuffs" | "other" local function IsOtherTab() - return activeBuffTab == "other" + return S.activeBuffTab == "other" end -- C2: the Debuffs tab hosts debuff CATEGORY groups (spec-independent, no -- spell pool, no placed indicators). Its Effects sub-tab frosts, the spec -- dropdown greys, and CurrentAuraPool reads empty. local function IsDebuffTab() - return activeBuffTab == "debuffs" + return S.activeBuffTab == "debuffs" end -- Read-only placeholder returned while the other pool doesn't exist yet. @@ -1088,7 +1105,7 @@ local EMPTY_POOL = {} -- READ access to the active tab's pool. Never creates adDB.otherAuras. -- `spec` is forwarded to GetSpecAuras on the My Buffs tab only. local function CurrentAuraPool(spec) - if activeBuffTab == "other" then + if S.activeBuffTab == "other" then local adDB = GetAuraDesignerDB() if adDB and adDB.otherAuras then return GetOtherAuras() end return EMPTY_POOL @@ -1096,14 +1113,14 @@ local function CurrentAuraPool(spec) -- The Debuffs tab has no aura pool: no placed indicators and no -- frame-level effects, so every pool-routed surface (preview passes, -- effect list) reads empty. Category groups live in adDB.debuffGroups. - if activeBuffTab == "debuffs" then return EMPTY_POOL end + if S.activeBuffTab == "debuffs" then return EMPTY_POOL end return GetSpecAuras(spec) end -- WRITE access: creates the pool table (the other pool is born lazily on -- the first add — drag-drop, picker click, or add-by-ID). local function CurrentAuraPoolWrite() - if activeBuffTab == "other" then return GetOtherAuras() end + if S.activeBuffTab == "other" then return GetOtherAuras() end return GetSpecAuras() end @@ -1112,7 +1129,7 @@ end -- (expandedCards "placed:other:#" / "frame::other:", -- preview slot keys). The auraName itself never carries the prefix. local function PoolKeyPrefix() - return (activeBuffTab == "other") and "other:" or "" + return (S.activeBuffTab == "other") and "other:" or "" end -- READ access to the debuff category groups array (C2). Never creates @@ -1164,7 +1181,7 @@ end -- (Debuffs never reaches these — its Layout Groups tab builds debuff -- category groups instead.) local function CurrentLayoutGroups() - if activeBuffTab == "other" then return GetOtherLayoutGroups(false) end + if S.activeBuffTab == "other" then return GetOtherLayoutGroups(false) end return GetSpecLayoutGroups() end @@ -1735,14 +1752,14 @@ local function GetIndicatorByID(auraName, indicatorID, pool) end -- Remove an indicator instance by its stable ID -local CleanupAdHocAura -- forward decl: defined after FRAME_LEVEL_TYPE_KEYS +-- (S.CleanupAdHocAura declared on the state table) local function RemoveIndicatorInstance(auraName, indicatorID) local auraCfg = CurrentAuraPool()[auraName] if not auraCfg or not auraCfg.indicators then return end for i, inst in ipairs(auraCfg.indicators) do if inst.id == indicatorID then table.remove(auraCfg.indicators, i) - if CleanupAdHocAura then CleanupAdHocAura(auraName) end + if S.CleanupAdHocAura then S.CleanupAdHocAura(auraName) end return end end @@ -1798,16 +1815,16 @@ end -- Forward declaration: lightweight preview refresh (defined after RefreshPreviewEffects) -- Called from proxy __newindex so every setting change updates the preview in real-time -local RefreshPreviewLightweight +-- (S.RefreshPreviewLightweight declared on the state table) -- Throttled live-frame refresh: re-syncs the factory containers on all -- visible AD-enabled frames. Debounced so rapid slider drags only trigger one refresh. -local pendingLiveRefresh = false +S.pendingLiveRefresh = false local function RefreshLiveFramesThrottled() - if pendingLiveRefresh then return end - pendingLiveRefresh = true + if S.pendingLiveRefresh then return end + S.pendingLiveRefresh = true C_Timer.After(0.1, function() - pendingLiveRefresh = false + S.pendingLiveRefresh = false local engine = DF.AuraDesigner and DF.AuraDesigner.Engine if engine and engine.ForceRefreshAllFrames then engine:ForceRefreshAllFrames() @@ -1866,9 +1883,9 @@ local function AddExpiryAlertControls(g, parent, proxy, include) anchorOptions = OPTS.ANCHOR_OPTIONS, include = include, -- Sub-table colour / alpha writes skip the proxy __newindex, so drive the refresh by - -- hand (RefreshPreviewLightweight is assigned by editor open; mirrors the card's RPL). + -- hand (S.RefreshPreviewLightweight is assigned by editor open; mirrors the card's RPL). fullUpdate = function() - if RefreshPreviewLightweight then RefreshPreviewLightweight() end + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end, -- A mode change collapses the now-irrelevant rows: LayoutChildren re-evaluates hideOn, @@ -1883,9 +1900,9 @@ local function AddExpiryAlertControls(g, parent, proxy, include) g:RefreshChildStates() -- initial grey (the initial hide rides AddGroup's LayoutChildren) end --- Colours-page cross-link placed under an AD "Color by Time Remaining" TEXT control, matching +-- Colours-S.page cross-link placed under an AD "Color by Time Remaining" TEXT control, matching -- the aura pages' duration link (jump + whole-section flash). The duration text's By-Time colour --- draws from the shared Colours-page breakpoints, so the link points there. Fixed-layout note, so +-- draws from the shared Colours-S.page breakpoints, so the link points there. Fixed-layout note, so -- size it to the group's inner width up front (the group advances Y by the height we pass). -- Built once in GUI:CreateColorsPageLink. NOT for the bar FILL colour (fixed ramp, immutable). local function AddDurationColorsLink(g, parent) @@ -1947,7 +1964,7 @@ local function CreateInstanceProxy(auraName, indicatorID) local inst = GetIndicatorByID(auraName, indicatorID, pool()) if not inst then return end inst[k] = v - if RefreshPreviewLightweight then RefreshPreviewLightweight() end + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end, }) @@ -1984,7 +2001,7 @@ local function CreateProxy(auraName, typeKey) __newindex = function(_, k, v) local typeCfg = EnsureTypeConfig(auraName, typeKey, pool()) typeCfg[k] = v - if RefreshPreviewLightweight then RefreshPreviewLightweight() end + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end, }) @@ -2004,7 +2021,7 @@ local function CreateAuraProxy(auraName) __newindex = function(_, k, v) local auraCfg = EnsureAuraConfig(auraName, pool()) auraCfg[k] = v - if RefreshPreviewLightweight then RefreshPreviewLightweight() end + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end, }) @@ -2223,16 +2240,16 @@ end -- open helpers live below the tab system (OpenADPicker). -- ============================================================ -local adPickerHandle -- shared-picker handle (nil until the first open) -local adPickerDirty = false -- an add landed while the picker stayed open +-- (S.adPickerHandle declared on the state table) +S.adPickerDirty = false -- an add landed while the picker stayed open -- (the tab behind it is stale; rebuilt on close) -- Close the shared picker if it's open. Called on sub-tab, main-tab and -- spec switches: the picker's records/handlers capture the pool and effect -- from open time, so a stale open picker must not linger. local function CloseADPicker() - if adPickerHandle and adPickerHandle:IsOpen() then - adPickerHandle:Close() + if S.adPickerHandle and S.adPickerHandle:IsOpen() then + S.adPickerHandle:Close() end end @@ -2343,7 +2360,7 @@ end -- Delete a debuff category group by ID (C2). No member indicators to cascade -- (category groups own no placed indicators). The caller runs the FULL -- structural chain afterwards — the group's claimed categories return to the --- main debuff bar. Card keys are "dgroup:" (see BuildDebuffGroupsTab). +-- main debuff bar. Card keys are "dgroup:" (see S.BuildDebuffGroupsTab). local function DeleteDebuffGroup(groupID) local groups = DebuffGroupsRead() for i, group in ipairs(groups) do @@ -2416,16 +2433,16 @@ local ANCHOR_POSITIONS = { -- FRAME REFERENCES (populated during build) -- Declared early so drag/indicator/effects code can capture them -- ============================================================ -local mainFrame -- The root frame for the entire page -local leftPanel -- Left content area (frame preview) -local rightPanel -- Right settings panel (tabbed) -local enableBanner -- Enable toggle banner -local framePreview -- Mock unit frame preview -local dragHintText -- Dynamic hint text below frame preview +-- (S.mainFrame declared on the state table) +-- (S.leftPanel declared on the state table) +-- (S.rightPanel declared on the state table) +-- (S.enableBanner declared on the state table) +-- (S.framePreview declared on the state table) +-- (S.dragHintText declared on the state table) -- Layout anchors — stored during build -local contentRightInset -- Right inset for left-side panels -local origY_framePreview -- original yPos of framePreview +-- (S.contentRightInset declared on the state table) +-- (S.origY_framePreview declared on the state table) -- (The DF_AURA_DESIGNER_RESET_GLOBAL popup was retired with the editing-banner -- "Reset to Global" button — the preset dropdown's "Inherit (Global)" entry now @@ -2434,18 +2451,18 @@ local origY_framePreview -- original yPos of framePreview -- ============================================================ -- UI STATE (v4 redesign — tabbed right panel) -- ============================================================ -local activeTab = "effects" -- "effects" | "layout" | "global" -local activeFilter = "all" -- Filter chip state +S.activeTab = "effects" -- "effects" | "layout" | "global" +S.activeFilter = "all" -- Filter chip state local expandedCards = {} -- { ["placed:AuraName#1"] = true, ["frame:border:AuraName"] = true } -- Tab system frame references -local tabBar -- Tab bar frame (Effects | Layout Groups | Global) +-- (S.tabBar declared on the state table) local tabButtons = {} -- { effects = btn, layout = btn, global = btn } local mainTabButtons = {} -- { my = btn, other = btn } (B2 main pool tab strip) -local specDropdown -- shared spec dropdown (lives on the main tab strip) -local specDropdownUpdate -- rebuilds the spec dropdown options + opener text -local tabContentFrame -- Scrollable content area below tabs -local tabScrollFrame -- ScrollFrame wrapping tabContentFrame +-- (S.specDropdown declared on the state table) +-- (S.specDropdownUpdate declared on the state table) +-- (S.tabContentFrame declared on the state table) +-- (S.tabScrollFrame declared on the state table) local effectCardPool = {} -- Reusable card frames -- ============================================================ @@ -2463,7 +2480,7 @@ local FRAME_LEVEL_TYPE_KEYS = { "border", "healthbar", "background", "nametext", -- phantom entry (group/trigger pickers, SavedVariables growth). Curated -- auras deliberately keep today's linger behavior. Forward-declared above -- RemoveIndicatorInstance, which calls it after every removal. -CleanupAdHocAura = function(auraName) +S.CleanupAdHocAura = function(auraName) if not AdHocSpellID(auraName) then return end local auras = CurrentAuraPool() local auraCfg = auras and auras[auraName] @@ -2478,11 +2495,11 @@ end -- Effect-type display labels. Same file-scope-vs-overlay timing issue as the -- option tables near the top of this file: build them in a registered refresh -- fn so they pick up the active locale. -local FRAME_LEVEL_LABELS = {} -local PLACED_TYPE_LABELS = {} +S.FRAME_LEVEL_LABELS = {} +S.PLACED_TYPE_LABELS = {} local function RefreshEffectLabels() - FRAME_LEVEL_LABELS = { + S.FRAME_LEVEL_LABELS = { border = L["Border"], healthbar = L["Health Bar"], background = L["Background"], @@ -2491,7 +2508,7 @@ local function RefreshEffectLabels() sound = L["Sound Alert"], } - PLACED_TYPE_LABELS = { + S.PLACED_TYPE_LABELS = { icon = L["Icon"], square = L["Square"], bar = L["Bar"], @@ -2621,42 +2638,42 @@ local dragState = { indicatorType = nil, -- "icon" | "square" | "bar" — type to create on drop } -local dragGhost = nil -local dragUpdateFrame = nil +S.dragGhost = nil +S.dragUpdateFrame = nil local function CreateDragGhost() - if dragGhost then return dragGhost end + if S.dragGhost then return S.dragGhost end - dragGhost = CreateFrame("Frame", "DFAuraDesignerDragGhost", UIParent, "BackdropTemplate") - dragGhost:SetSize(36, 36) - dragGhost:SetFrameStrata("TOOLTIP") - dragGhost:SetFrameLevel(1000) - dragGhost:EnableMouse(false) -- KEY: mouse events pass through to drop targets - dragGhost:Hide() + S.dragGhost = CreateFrame("Frame", "DFAuraDesignerDragGhost", UIParent, "BackdropTemplate") + S.dragGhost:SetSize(36, 36) + S.dragGhost:SetFrameStrata("TOOLTIP") + S.dragGhost:SetFrameLevel(1000) + S.dragGhost:EnableMouse(false) -- KEY: mouse events pass through to drop targets + S.dragGhost:Hide() - if not dragGhost.SetBackdrop then Mixin(dragGhost, BackdropTemplateMixin) end - DF.GUI:CreateElementBackdrop(dragGhost, { + if not S.dragGhost.SetBackdrop then Mixin(S.dragGhost, BackdropTemplateMixin) end + DF.GUI:CreateElementBackdrop(S.dragGhost, { edgeSize = 2, bgColor = { 0.05, 0.05, 0.05, 0.9 }, }) -- Spell icon - local icon = dragGhost:CreateTexture(nil, "ARTWORK") + local icon = S.dragGhost:CreateTexture(nil, "ARTWORK") icon:SetPoint("TOPLEFT", 3, -3) icon:SetPoint("BOTTOMRIGHT", -3, 3) icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) - dragGhost.icon = icon + S.dragGhost.icon = icon -- Name label under ghost - local label = dragGhost:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("TOP", dragGhost, "BOTTOM", 0, -2) + local label = S.dragGhost:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("TOP", S.dragGhost, "BOTTOM", 0, -2) label:SetTextColor(1, 1, 1, 0.8) - dragGhost.label = label + S.dragGhost.label = label - return dragGhost + return S.dragGhost end -local EndDrag -- forward declaration (defined below StartMoveDrag) +-- (S.EndDrag declared on the state table) -- Start a move-drag for an existing placed indicator (the ghost + -- cursor-following + anchor-dot system; new-placement drags died with the @@ -2704,9 +2721,9 @@ local function StartMoveDrag(auraName, indicatorID, specKey) ghost:Show() -- Show drag hint - if dragHintText then - dragHintText:SetText(format(L["Drop on an anchor point to move %s"], displayName)) - dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) + if S.dragHintText then + S.dragHintText:SetText(format(L["Drop on an anchor point to move %s"], displayName)) + S.dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) end -- Show and enlarge all anchor dots @@ -2718,12 +2735,12 @@ local function StartMoveDrag(auraName, indicatorID, specKey) end -- Start cursor following - if not dragUpdateFrame then - dragUpdateFrame = CreateFrame("Frame") + if not S.dragUpdateFrame then + S.dragUpdateFrame = CreateFrame("Frame") end - dragUpdateFrame:SetScript("OnUpdate", function() + S.dragUpdateFrame:SetScript("OnUpdate", function() if not dragState.isDragging then - dragUpdateFrame:Hide() + S.dragUpdateFrame:Hide() return end @@ -2735,13 +2752,13 @@ local function StartMoveDrag(auraName, indicatorID, specKey) ghost:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cursorX + 10, cursorY - 10) if not IsMouseButtonDown("LeftButton") then - EndDrag() + S.EndDrag() end end) - dragUpdateFrame:Show() + S.dragUpdateFrame:Show() end -EndDrag = function() +S.EndDrag = function() if not dragState.isDragging then return end local auraName = dragState.auraName @@ -2759,17 +2776,17 @@ EndDrag = function() dragState.indicatorType = nil -- Hide ghost - if dragGhost then dragGhost:Hide() end + if S.dragGhost then S.dragGhost:Hide() end -- Stop cursor following - if dragUpdateFrame then - dragUpdateFrame:Hide() - dragUpdateFrame:SetScript("OnUpdate", nil) + if S.dragUpdateFrame then + S.dragUpdateFrame:Hide() + S.dragUpdateFrame:SetScript("OnUpdate", nil) end -- Clear drag hint - if dragHintText then - dragHintText:SetText("") + if S.dragHintText then + S.dragHintText:SetText("") end -- Hide anchor dots (only visible during drag) @@ -2818,14 +2835,14 @@ end local placedIndicators = {} --- Set by ClearPlacedIndicators, consumed by the page's RefreshStates: tab --- switching re-enters the page through GUI RefreshCached -> RefreshStates ONLY --- (the builder is cache-skipped), and RefreshStates early-outs when the page +-- Set by ClearPlacedIndicators, consumed by the S.page's RefreshStates: tab +-- switching re-enters the S.page through GUI RefreshCached -> RefreshStates ONLY +-- (the builder is cache-skipped), and RefreshStates early-outs when the S.page -- dimensions are unchanged — so a canvas cleared by the OnHide hook stayed -- blank on every same-size revisit (the first revisit only worked because the -- dims baseline was captured pre-layout). The flag lets the dimension guard -- distinguish "nothing changed" from "cleared and awaiting repaint". -local placedCleared = false +S.placedCleared = false local function ClearPlacedIndicators() -- Preview border ANIMATIONS must be stopped explicitly on every frame this @@ -2852,8 +2869,8 @@ local function ClearPlacedIndicators() wipe(placedIndicators) -- Clean up AD indicator maps on the mockFrame - if framePreview and framePreview.mockFrame then - local mock = framePreview.mockFrame + if S.framePreview and S.framePreview.mockFrame then + local mock = S.framePreview.mockFrame if mock.dfADPreviewSlots then for _, rec in pairs(mock.dfADPreviewSlots) do stopAnims(rec.slot) @@ -2865,7 +2882,7 @@ local function ClearPlacedIndicators() end mock.dfAD = nil end - placedCleared = true + S.placedCleared = true end -- ============================================================ @@ -3020,7 +3037,7 @@ local function WirePreviewIndicator(slot, capturedAura, capturedID, spec) local cardKey = "placed:" .. PoolKeyPrefix() .. capturedAura .. "#" .. capturedID wipe(expandedCards) expandedCards[cardKey] = true - activeTab = "effects" + S.activeTab = "effects" DF:AuraDesigner_RefreshPage() end end) @@ -3265,10 +3282,10 @@ end local function RefreshPlacedIndicators() ClearPlacedIndicators() - placedCleared = false -- this pass IS the repaint (after Clear re-armed the flag) - if not framePreview then return end + S.placedCleared = false -- this pass IS the repaint (after Clear re-armed the flag) + if not S.framePreview then return end - local mockFrame = framePreview.mockFrame + local mockFrame = S.framePreview.mockFrame if not mockFrame then return end local adDB = GetAuraDesignerDB() @@ -3461,13 +3478,13 @@ local function GetOrCreatePreviewCustomBorder(mockFrame, key) end local function RefreshPreviewEffects() - if not framePreview then return end - local mockFrame = framePreview.mockFrame + if not S.framePreview then return end + local mockFrame = S.framePreview.mockFrame if not mockFrame then return end -- Reset shared border overlay (Stage 5.4: DF.Border — hide edges + anim) - if framePreview.borderOverlay then - DF.Border:Apply(framePreview.borderOverlay, { enabled = false }) + if S.framePreview.borderOverlay then + DF.Border:Apply(S.framePreview.borderOverlay, { enabled = false }) end -- Reset custom border overlays if mockFrame.dfPreviewCustomBorders then @@ -3475,14 +3492,14 @@ local function RefreshPreviewEffects() DF.Border:Apply(ch, { enabled = false }) end end - if framePreview.healthFill then - framePreview.healthFill:SetVertexColor(0.18, 0.80, 0.44, 0.85) + if S.framePreview.healthFill then + S.framePreview.healthFill:SetVertexColor(0.18, 0.80, 0.44, 0.85) end - if framePreview.nameText then - framePreview.nameText:SetTextColor(0.18, 0.80, 0.44, 1) + if S.framePreview.nameText then + S.framePreview.nameText:SetTextColor(0.18, 0.80, 0.44, 1) end - if framePreview.hpText then - framePreview.hpText:SetTextColor(0.87, 0.87, 0.87, 1) + if S.framePreview.hpText then + S.framePreview.hpText:SetTextColor(0.87, 0.87, 0.87, 1) end mockFrame:SetAlpha(1) -- Reset the shared single-target elements to their defaults ONCE, before any @@ -3490,11 +3507,11 @@ local function RefreshPreviewEffects() -- in-loop `elseif`, so a background-less aura could wipe an earlier aura's -- background depending on pairs() iteration order — the intermittent -- "doesn't show on preview" report for Background / Health Bar Color. - if framePreview.healthBg then - framePreview.healthBg:SetColorTexture(0, 0, 0, 0.4) + if S.framePreview.healthBg then + S.framePreview.healthBg:SetColorTexture(0, 0, 0, 0.4) end - if framePreview.missingHealth then - framePreview.missingHealth:SetColorTexture(0, 0, 0, 0.4) + if S.framePreview.missingHealth then + S.framePreview.missingHealth:SetColorTexture(0, 0, 0, 0.4) end -- Frame-level effects all draw onto the SAME single preview elements (one @@ -3532,19 +3549,19 @@ local function RefreshPreviewEffects() spec.enabled = true if auraCfg.border.borderMode == "custom" then DF.Border:Apply(GetOrCreatePreviewCustomBorder(mockFrame, auraName), spec) - elseif not claimed.border and framePreview.borderOverlay then + elseif not claimed.border and S.framePreview.borderOverlay then claimed.border = true - DF.Border:Apply(framePreview.borderOverlay, spec) + DF.Border:Apply(S.framePreview.borderOverlay, spec) end end -- Health bar color (first claim wins) - if not claimed.healthbar and auraCfg.healthbar and auraCfg.healthbar.enabled ~= false and framePreview.healthFill then + if not claimed.healthbar and auraCfg.healthbar and auraCfg.healthbar.enabled ~= false and S.framePreview.healthFill then claimed.healthbar = true local clr = auraCfg.healthbar.color or {r = 1, g = 1, b = 1, a = 1} local blend = auraCfg.healthbar.blend or 0.5 if auraCfg.healthbar.mode == "Replace" then - framePreview.healthFill:SetVertexColor(clr.r, clr.g, clr.b, clr.a or 1) + S.framePreview.healthFill:SetVertexColor(clr.r, clr.g, clr.b, clr.a or 1) else -- Tint: blend original green with the configured color, scaled by alpha -- so dragging the colour picker's alpha visibly weakens the tint @@ -3553,42 +3570,42 @@ local function RefreshPreviewEffects() local r = 0.18 * (1 - effBlend) + clr.r * effBlend local g = 0.80 * (1 - effBlend) + clr.g * effBlend local b = 0.44 * (1 - effBlend) + clr.b * effBlend - framePreview.healthFill:SetVertexColor(r, g, b, 1) + S.framePreview.healthFill:SetVertexColor(r, g, b, 1) -- Tint Entire Bar: paint the same tint hue over the (dark) missing- -- health region so the preview shows the colour spanning the full bar. - if auraCfg.healthbar.tintWholeBar and framePreview.missingHealth then - framePreview.missingHealth:SetColorTexture(clr.r * effBlend, clr.g * effBlend, clr.b * effBlend, 0.4 + 0.25 * effBlend) + if auraCfg.healthbar.tintWholeBar and S.framePreview.missingHealth then + S.framePreview.missingHealth:SetColorTexture(clr.r * effBlend, clr.g * effBlend, clr.b * effBlend, 0.4 + 0.25 * effBlend) end end end -- Background color (first claim wins). Recolours the frame background — shows -- through the missing-health area, like the runtime overlay behind the bars. - if not claimed.background and auraCfg.background and auraCfg.background.enabled ~= false and framePreview.healthBg then + if not claimed.background and auraCfg.background and auraCfg.background.enabled ~= false and S.framePreview.healthBg then claimed.background = true local clr = auraCfg.background.color or {r = 1, g = 1, b = 1, a = 1} if auraCfg.background.mode == "Replace" then local a = clr.a or 1 - framePreview.healthBg:SetColorTexture(clr.r, clr.g, clr.b, 0.4 + 0.6 * a) + S.framePreview.healthBg:SetColorTexture(clr.r, clr.g, clr.b, 0.4 + 0.6 * a) else local blend = (auraCfg.background.blend or 0.5) * (clr.a or 1) -- Blend the configured colour over the dark default background. - framePreview.healthBg:SetColorTexture(clr.r * blend, clr.g * blend, clr.b * blend, 0.4 + 0.4 * blend) + S.framePreview.healthBg:SetColorTexture(clr.r * blend, clr.g * blend, clr.b * blend, 0.4 + 0.4 * blend) end end -- Name text color (first claim wins) - if not claimed.nametext and auraCfg.nametext and auraCfg.nametext.enabled ~= false and framePreview.nameText then + if not claimed.nametext and auraCfg.nametext and auraCfg.nametext.enabled ~= false and S.framePreview.nameText then claimed.nametext = true local clr = auraCfg.nametext.color or {r = 1, g = 1, b = 1, a = 1} - framePreview.nameText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) + S.framePreview.nameText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) end -- Health text color (first claim wins) - if not claimed.healthtext and auraCfg.healthtext and auraCfg.healthtext.enabled ~= false and framePreview.hpText then + if not claimed.healthtext and auraCfg.healthtext and auraCfg.healthtext.enabled ~= false and S.framePreview.hpText then claimed.healthtext = true local clr = auraCfg.healthtext.color or {r = 1, g = 1, b = 1, a = 1} - framePreview.hpText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) + S.framePreview.hpText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) end end -- for _, entry in sortedAuras @@ -3601,9 +3618,9 @@ end -- slider drag tick, checkbox toggle, or dropdown change is live. -- ============================================================ -RefreshPreviewLightweight = function() - if not framePreview or not framePreview.mockFrame then return end - local mockFrame = framePreview.mockFrame +S.RefreshPreviewLightweight = function() + if not S.framePreview or not S.framePreview.mockFrame then return end + local mockFrame = S.framePreview.mockFrame local adDB = GetAuraDesignerDB() local isOther = IsOtherTab() @@ -3877,7 +3894,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff end -- Color picker callback shorthand — refreshes both the AD preview and live frames - local function RPL() if RefreshPreviewLightweight then RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end + local function RPL() if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end -- Shared Duration Bar section for the icon / square placed indicators — both carry -- durationBar* keys and the strip hangs off the slot edge regardless of shape. Same @@ -3946,7 +3963,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], proxy, "hideSwipe"), 28) -- Text-only mode: the icon TEXTURE is hidden, so a border (static OR - -- expiring) would frame nothing. Rebuild the page on toggle so the + -- expiring) would frame nothing. Rebuild the S.page on toggle so the -- Border group + the expiring-border thickness control hide/show -- (gated on proxy.hideIcon below) — matching the runtime, which -- force-disables both borders in this mode. @@ -3997,7 +4014,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff gradient = true, shadow = true, alpha = true, }, -- IMPORTANT: AD's per-aura proxy only triggers - -- RefreshLiveFramesThrottled + RefreshPreviewLightweight + -- RefreshLiveFramesThrottled + S.RefreshPreviewLightweight -- on direct key assignment (proxy.X = v) via __newindex. -- CreateColorPicker and the Border Alpha slider mutate -- SUB-TABLE fields (proxy.BorderColor.a = v) which reads @@ -4358,19 +4375,19 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff -- offered here — only a Text/Glyph alert. A note whose click jump-scrolls the card up to -- the Color Mode. (A full InfoBanner link-banner can't live in this AD card — html needs -- a post-SetWidth reflow the card's static-height path disables — so the whole note is the - -- click target, with "Color Mode" tinted to signal it, like the cross-page links.) + -- click target, with "Color Mode" tinted to signal it, like the cross-S.page links.) AddGroup(L["Expiration"], function(g) -- Note with a jump-link: only "Color Mode" is clickable — clicking scrolls the card -- up to Texture & Colors and flashes it. GUI:CreateLink = fixed-layout inline links -- (safe in the reflowing card); GUI:LinkToSetting = scroll + "show me" flash. The |c -- placeholder only satisfies the markup parser; CreateLink themes the link itself. local function scrollToTexColors() - if not (tabScrollFrame and texColorsGroup and texColorsGroup.GetTop) then return end - local sfTop, gTop = tabScrollFrame:GetTop(), texColorsGroup:GetTop() + if not (S.tabScrollFrame and texColorsGroup and texColorsGroup.GetTop) then return end + local sfTop, gTop = S.tabScrollFrame:GetTop(), texColorsGroup:GetTop() if not (sfTop and gTop) then return end - local target = tabScrollFrame:GetVerticalScroll() + (sfTop - gTop) - 8 - local maxS = tabScrollFrame:GetVerticalScrollRange() or 0 - tabScrollFrame:SetVerticalScroll(math.max(0, math.min(maxS, target))) + local target = S.tabScrollFrame:GetVerticalScroll() + (sfTop - gTop) - 8 + local maxS = S.tabScrollFrame:GetVerticalScrollRange() or 0 + S.tabScrollFrame:SetVerticalScroll(math.max(0, math.min(maxS, target))) end local link = format("|cffffffff|HdfADScroll:texcolors|h%s|h|r", L["Color Mode"]) -- Fixed-layout note: hand it the group's inner width so its wrapped (2-line) height is @@ -4783,7 +4800,7 @@ local function BuildGlobalView(parent) local contentWidth = parentW - 16 -- 8px padding each side local totalHeight = 8 local widgets = {} - local function RPL() if RefreshPreviewLightweight then RefreshPreviewLightweight() end end + local function RPL() if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end end local function AddWidget(widget, height) widget:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -totalHeight) @@ -4962,7 +4979,7 @@ local function BuildGlobalView(parent) descText:SetPoint("RIGHT", descFrame, "RIGHT", 0, 0) descText:SetJustifyH("LEFT") descText:SetWordWrap(true) - descText:SetText(L["Standard buff visibility is managed on the Aura Filters page."]) + descText:SetText(L["Standard buff visibility is managed on the Aura Filters S.page."]) descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) g:AddWidget(descFrame, 24) @@ -4993,7 +5010,7 @@ local function BuildGlobalView(parent) -- Copy at the preset level: the source mode's preset content is -- copied INTO the dest mode's preset, in place, so the dest preset -- object identity (and every consumer bound to it) is preserved. - -- BASE resolvers: this page edits the user's BASE presets — with a + -- BASE resolvers: this S.page edits the user's BASE presets — with a -- runtime auto-layout active, the ACTIVE resolver would copy -- from/into the layout's preset instead. local source = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(srcMode)) @@ -5063,7 +5080,7 @@ local function CreateEnableBanner(parent) local banner = CreateFrame("Frame", nil, parent, "BackdropTemplate") -- Two-row layout: row 1 (36px) has Enable toggle (left) + Sync/Copy buttons -- (right); row 2 (32px) is the preset bar (anchored into the banner by the - -- page build; the spec dropdown moved onto the B2 main tab strip). Sound + -- S.page build; the spec dropdown moved onto the B2 main tab strip). Sound -- Alerts live on the Global tab (set-once settings). banner:SetHeight(68) banner:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0) @@ -5093,7 +5110,7 @@ local function CreateEnableBanner(parent) -- Show popup asking about buff coexistence ShowBuffCoexistPopup(function(keepBuffs) GetAuraDesignerDB().enabled = true - db.showBuffs = keepBuffs + S.db.showBuffs = keepBuffs DF:AuraDesigner_RefreshPage() DF:InvalidateAuraLayout() DF:UpdateAllFrames() @@ -5135,7 +5152,7 @@ end local function CreateSpecDropdown(parent) -- Spec selector. Ported to the shared GUI:CreateDropdown (inline mode, so the -- container is just the opener button — the "Spec:" label beside it is - -- hand-placed by the page build). optionsFunc rebuilds the list each open so + -- hand-placed by the S.page build). optionsFunc rebuilds the list each open so -- the "Auto (Spec Name)" text always reflects the live detected spec. -- The shared dropdown supports per-option colour (the `color` field), so the -- class-coloured menu entries are preserved. (The OPENER text stays standard @@ -5270,7 +5287,7 @@ local function CreateFramePreview(parent, yOffset, rightPanelRef) -- Dark bg + DIM border (matches Text Designer; no solid white outline). ApplyBackdrop(container, {r = 0.10, g = 0.10, b = 0.10, a = 1}, {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}) -- Apply the subtle spec class-color hint immediately. CreateFramePreview runs - -- on every page build — including a party/raid rebuild (page:Refresh always + -- on every S.page build — including a party/raid rebuild (S.page:Refresh always -- rebuilds) — so without this the new preview falls back to the dim default -- border until the next AuraDesigner_RefreshPage (spec change / tab revisit). local cbSpec = ResolveSpec() @@ -5433,8 +5450,8 @@ local function CreateFramePreview(parent, yOffset, rightPanelRef) dot:SetColorTexture(tc.r, tc.g, tc.b, 0.9) dragState.dropAnchor = capturedAnchorName -- Update hint to show target anchor - if dragHintText and dragState.auraInfo then - dragHintText:SetText(format(L["Place %s at %s"], dragState.auraInfo.display, capturedAnchorName)) + if S.dragHintText and dragState.auraInfo then + S.dragHintText:SetText(format(L["Place %s at %s"], dragState.auraInfo.display, capturedAnchorName)) end else local tc = GetThemeColor() @@ -5450,9 +5467,9 @@ local function CreateFramePreview(parent, yOffset, rightPanelRef) dot:SetColorTexture(tc.r, tc.g, tc.b, 0.5) dragState.dropAnchor = nil -- Revert hint to generic drag message - if dragHintText and dragState.auraInfo then - dragHintText:SetText(format(L["Drop on an anchor point to place %s"], dragState.auraInfo.display)) - dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) + if S.dragHintText and dragState.auraInfo then + S.dragHintText:SetText(format(L["Drop on an anchor point to place %s"], dragState.auraInfo.display)) + S.dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) end else local tc = GetThemeColor() @@ -5519,11 +5536,11 @@ local function CreateFramePreview(parent, yOffset, rightPanelRef) scaleSlider:SetSize(220, 30) -- Drag-state hint text (shows contextual guidance during drag operations) - dragHintText = container:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(dragHintText, 9, "OUTLINE") - dragHintText:SetPoint("TOP", mockFrame, "BOTTOM", 0, -6) - dragHintText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) - dragHintText:SetText("") + S.dragHintText = container:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(S.dragHintText, 9, "OUTLINE") + S.dragHintText:SetPoint("TOP", mockFrame, "BOTTOM", 0, -6) + S.dragHintText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) + S.dragHintText:SetText("") return container end @@ -5535,13 +5552,13 @@ end -- ============================================================ -- Forward declarations (mutually referencing functions) -local SwitchTab -local BuildEffectsTab, BuildGlobalTab, BuildLayoutGroupsTab, BuildDebuffGroupsTab -local CreateEffectCard +-- (S.SwitchTab declared on the state table) +-- (S.BuildEffectsTab, S.BuildGlobalTab, S.BuildLayoutGroupsTab, S.BuildDebuffGroupsTab declared on the state table) +-- (S.CreateEffectCard declared on the state table) -local spellPickerBlockedIDs -- spell ids tracked by the OPPOSITE pool (B2 +-- (S.spellPickerBlockedIDs declared on the state table) -- cross-tab block; rebuilt per picker open) -local spellPickerBlockCache = {} -- auraName -> bool memo over spellPickerBlockedIDs +local spellPickerBlockCache = {} -- auraName -> bool memo over S.spellPickerBlockedIDs -- (wiped whenever the set is rebuilt) so blocked -- checks don't re-resolve identity per row bind @@ -5549,7 +5566,7 @@ local spellPickerBlockCache = {} -- auraName -> bool memo over spellPickerBloc -- (nil-spec identity on the Other tab — the naming contract's resolver — -- else the spec identity) already tracked by the opposite pool. local function IsCandidateCrossBlocked(auraName, spec) - if not spellPickerBlockedIDs or not next(spellPickerBlockedIDs) then return false end + if not S.spellPickerBlockedIDs or not next(S.spellPickerBlockedIDs) then return false end local cached = spellPickerBlockCache[auraName] if cached ~= nil then return cached end local blocked = false @@ -5557,7 +5574,7 @@ local function IsCandidateCrossBlocked(auraName, spec) local map = f and f.includeSpellIDs if map then for id in pairs(map) do - if spellPickerBlockedIDs[id] then blocked = true; break end + if S.spellPickerBlockedIDs[id] then blocked = true; break end end end spellPickerBlockCache[auraName] = blocked @@ -5572,13 +5589,13 @@ end -- Clear all child frames and regions from the tab content area local function ClearTabContent() - if not tabContentFrame then return end - local children = { tabContentFrame:GetChildren() } + if not S.tabContentFrame then return end + local children = { S.tabContentFrame:GetChildren() } for _, child in ipairs(children) do child:Hide() child:ClearAllPoints() end - local regions = { tabContentFrame:GetRegions() } + local regions = { S.tabContentFrame:GetRegions() } for _, region in ipairs(regions) do region:Hide() end @@ -5589,12 +5606,12 @@ end -- programmatic/ancestor hide). Brings the tab surfaces back and rebuilds -- the active tab when an add landed while the picker stayed open. local function ADPickerClosed() - spellPickerBlockedIDs = nil -- recomputed on next open (memo wiped with it) - if tabBar then tabBar:Show() end - if tabScrollFrame then tabScrollFrame:Show() end - if adPickerDirty then - adPickerDirty = false - SwitchTab(activeTab or "effects") + S.spellPickerBlockedIDs = nil -- recomputed on next open (memo wiped with it) + if S.tabBar then S.tabBar:Show() end + if S.tabScrollFrame then S.tabScrollFrame:Show() end + if S.adPickerDirty then + S.adPickerDirty = false + S.SwitchTab(S.activeTab or "effects") end end @@ -5603,12 +5620,12 @@ end -- hide the tab surfaces the overlay replaces, and open the shared picker -- over the right panel. local function OpenADPicker(opts) - spellPickerBlockedIDs = CrossPoolTrackedIDs() + S.spellPickerBlockedIDs = CrossPoolTrackedIDs() wipe(spellPickerBlockCache) - adPickerDirty = false - if tabBar then tabBar:Hide() end - if tabScrollFrame then tabScrollFrame:Hide() end - opts.parent = rightPanel + S.adPickerDirty = false + if S.tabBar then S.tabBar:Hide() end + if S.tabScrollFrame then S.tabScrollFrame:Hide() end + opts.parent = S.rightPanel opts.onClose = ADPickerClosed -- Empty record list on My Buffs = unsupported/undetected spec: keep -- the old picker's guidance instead of a bare "No results found". @@ -5616,7 +5633,7 @@ local function OpenADPicker(opts) if not IsOtherTab() then opts.emptyText = L["No trackable spells found for this spec.\n\nYou can select a different spec using the dropdown above."] end - adPickerHandle = DF.FilterRegistry:OpenSpellPicker(opts) + S.adPickerHandle = DF.FilterRegistry:OpenSpellPicker(opts) end -- ── PICKER RECORDS ── @@ -5680,7 +5697,7 @@ local function ADCrossBlockText(rec) end -- ── SWITCH TAB ── -SwitchTab = function(tabKey) +S.SwitchTab = function(tabKey) -- Effects is frosted on the Debuffs tab (C2: category groups have no -- placed indicators) — coerce to Layout Groups (belt-and-braces; the -- sub-tab button is also frosted). @@ -5688,16 +5705,16 @@ SwitchTab = function(tabKey) tabKey = "layout" end -- Preserve scroll position when refreshing the same tab - local prevTab = activeTab + local prevTab = S.activeTab local savedScroll = 0 - if tabKey == prevTab and tabScrollFrame then - savedScroll = tabScrollFrame:GetVerticalScroll() + if tabKey == prevTab and S.tabScrollFrame then + savedScroll = S.tabScrollFrame:GetVerticalScroll() end - activeTab = tabKey + S.activeTab = tabKey -- This switch rebuilds the tab anyway — skip the close hook's own -- dirty rebuild so the tab isn't built twice. - adPickerDirty = false + S.adPickerDirty = false CloseADPicker() if GUI then GUI:CloseAllMenus() end -- an open dropdown (e.g. spec) must not outlive the tab @@ -5708,27 +5725,27 @@ SwitchTab = function(tabKey) ClearTabContent() if tabKey == "effects" then - BuildEffectsTab() + S.BuildEffectsTab() elseif tabKey == "layout" then -- The Debuffs tab's Layout Groups list shows ONLY debuff category -- groups; My Buffs / Other Buffs each build their OWN pool's - -- member+filter groups (BuildLayoutGroupsTab is pool-routed). + -- member+filter groups (S.BuildLayoutGroupsTab is pool-routed). if IsDebuffTab() then - BuildDebuffGroupsTab() + S.BuildDebuffGroupsTab() else - BuildLayoutGroupsTab() + S.BuildLayoutGroupsTab() end elseif tabKey == "global" then - BuildGlobalTab() + S.BuildGlobalTab() end - if tabScrollFrame then + if S.tabScrollFrame then if tabKey == prevTab then -- Clamp to new max scroll range (content may have changed height) - local maxScroll = tabScrollFrame:GetVerticalScrollRange() - tabScrollFrame:SetVerticalScroll(min(savedScroll, maxScroll)) + local maxScroll = S.tabScrollFrame:GetVerticalScrollRange() + S.tabScrollFrame:SetVerticalScroll(min(savedScroll, maxScroll)) else - tabScrollFrame:SetVerticalScroll(0) + S.tabScrollFrame:SetVerticalScroll(0) end end end @@ -5755,24 +5772,24 @@ end -- tabs (both pools are shared across specs); restore the live spec text on -- My Buffs. local function UpdateSpecDropdownState() - if not specDropdown then return end + if not S.specDropdown then return end if IsOtherTab() or IsDebuffTab() then - if specDropdown.SetDisplayOverride then - specDropdown:SetDisplayOverride(L["— (shared across specs)"]) + if S.specDropdown.SetDisplayOverride then + S.specDropdown:SetDisplayOverride(L["— (shared across specs)"]) end - specDropdown:SetEnabled(false) + S.specDropdown:SetEnabled(false) else - specDropdown:SetEnabled(true) - if specDropdown.SetDisplayOverride then - specDropdown:SetDisplayOverride(nil) + S.specDropdown:SetEnabled(true) + if S.specDropdown.SetDisplayOverride then + S.specDropdown:SetDisplayOverride(nil) end - if specDropdownUpdate then specDropdownUpdate() end + if S.specDropdownUpdate then S.specDropdownUpdate() end end end local function SetMainTab(tabKey) - if activeBuffTab == tabKey then return end - activeBuffTab = tabKey + if S.activeBuffTab == tabKey then return end + S.activeBuffTab = tabKey -- Editor keys are pool-prefixed (B1) so cards can't collide across tabs, -- but mirror the spec dropdown's behavior: a pool switch collapses all -- expanded cards (wipe, not per-tab preservation). @@ -5789,10 +5806,10 @@ local function SetMainTab(tabKey) -- Effects is frosted on the Debuffs tab, so land on Layout Groups (the -- tab's primary surface). Layout Groups is live on both buff tabs — no -- coercion needed when arriving there. - if activeBuffTab == "debuffs" and activeTab == "effects" then - activeTab = "layout" + if S.activeBuffTab == "debuffs" and S.activeTab == "effects" then + S.activeTab = "layout" end - -- One entry point swaps every surface: RefreshPage → SwitchTab(activeTab) + -- One entry point swaps every surface: RefreshPage → S.SwitchTab(S.activeTab) -- (list, chips, add menu) + RefreshPlacedIndicators/RefreshPreviewEffects -- (preview, drag targets) — all pool-routed through CurrentAuraPool. DF:AuraDesigner_RefreshPage() @@ -5838,9 +5855,9 @@ local function AddSpellToGroup(groupID, auraName, display, typeKey, skipEcho, pi local instance = CreateIndicatorInstance(auraName, typeKey) if not instance then return end AddGroupMember(groupID, auraName, instance.id) - adPickerDirty = true -- layout tab behind the picker is stale; rebuilt on close + S.adPickerDirty = true -- layout tab behind the picker is stale; rebuilt on close if not skipEcho and picker then - local typeLabel = PLACED_TYPE_LABELS[typeKey] or typeKey + local typeLabel = S.PLACED_TYPE_LABELS[typeKey] or typeKey picker:Echo(format(L["Added %s."], format("%s (%s)", display or auraName, typeLabel))) end @@ -5858,7 +5875,7 @@ end -- Snap known ids to their pool/curated record (then behave exactly like -- clicking that spell's row — same AddPickedSpell / AddSpellToGroup paths); -- unknown ids become an ad-hoc "#" aura whose key IS its identity --- (CleanupAdHocAura drops the config again once its last effect is +-- (S.CleanupAdHocAura drops the config again once its last effect is -- removed). The picker stays open (echo confirms), so several ids can be -- added in a row. The shared picker has already validated the digits and -- normalized leading zeros; idText is that validated digit STRING. Returns @@ -5908,8 +5925,8 @@ local function ADAddByID(idNum, idText, picker, mode, typeKey, groupID) -- or the raw id for ad-hoc — is already tracked by the OPPOSITE pool. -- Checked before the group branch so group adds are blocked too. local crossBlocked = false - if spellPickerBlockedIDs and next(spellPickerBlockedIDs) then - if spellPickerBlockedIDs[idNum] then + if S.spellPickerBlockedIDs and next(S.spellPickerBlockedIDs) then + if S.spellPickerBlockedIDs[idNum] then crossBlocked = true elseif not isAdHoc then crossBlocked = IsCandidateCrossBlocked(auraName, spec) @@ -5959,7 +5976,7 @@ local function ADAddByID(idNum, idText, picker, mode, typeKey, groupID) end AddPickedSpell(auraName, typeKey, mode) - adPickerDirty = true + S.adPickerDirty = true if isAdHoc then picker:Echo(format(L["Added #%d as an unknown spell ID — name and icon will show if the ID is valid."], idNum)) else @@ -5985,13 +6002,13 @@ local function OpenIndicatorPicker(typeKey, mode) local badgeColor = BADGE_COLORS[typeKey] or BADGE_COLORS.icon local title if mode == "frame" then - title = format(L["Select trigger for %s"], FRAME_LEVEL_LABELS[typeKey] or typeKey) + title = format(L["Select trigger for %s"], S.FRAME_LEVEL_LABELS[typeKey] or typeKey) else title = L["Select a spell"] end OpenADPicker({ title = title, - subtitle = PLACED_TYPE_LABELS[typeKey] or FRAME_LEVEL_LABELS[typeKey] or typeKey, + subtitle = S.PLACED_TYPE_LABELS[typeKey] or S.FRAME_LEVEL_LABELS[typeKey] or typeKey, subtitleColor = badgeColor, records = function() return BuildADPickerRecords(false) end, classLock = (not isOther) and specInfo and specInfo.class or nil, @@ -6011,7 +6028,7 @@ local function OpenIndicatorPicker(typeKey, mode) handler = function(rec, _, picker) AddPickedSpell(rec.auraName, typeKey, mode) picker:Close() - SwitchTab("effects") + S.SwitchTab("effects") RefreshPlacedIndicators() RefreshPreviewEffects() end, @@ -6046,7 +6063,7 @@ local function OpenGroupSpellPicker(groupID) isBlocked = ADCrossBlockText, rowActions = { { - label = PLACED_TYPE_LABELS.icon or "Icon", + label = S.PLACED_TYPE_LABELS.icon or "Icon", color = BADGE_COLORS.icon, typeKey = "icon", handler = function(rec, _, picker) @@ -6054,7 +6071,7 @@ local function OpenGroupSpellPicker(groupID) end, }, { - label = PLACED_TYPE_LABELS.square or "Square", + label = S.PLACED_TYPE_LABELS.square or "Square", color = BADGE_COLORS.square, typeKey = "square", handler = function(rec, _, picker) @@ -6072,7 +6089,7 @@ end -- ── CREATE EFFECT CARD ── -- Creates a collapsible card for one effect in the effects list. -- Returns the new yPos after the card. -CreateEffectCard = function(parent, yPos, effect) +S.CreateEffectCard = function(parent, yPos, effect) local isPlaced = (effect.source == "placed") -- B1 key scheme: the pool prefix rides the NAME segment, so the two -- pools' expandedCards entries can never collide. @@ -6122,8 +6139,8 @@ CreateEffectCard = function(parent, yPos, effect) -- Type badge local badgeColor = BADGE_COLORS[effect.typeKey] or BADGE_COLORS.icon local typeLabel = isPlaced - and (PLACED_TYPE_LABELS[effect.typeKey] or effect.typeKey) - or (FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey) + and (S.PLACED_TYPE_LABELS[effect.typeKey] or effect.typeKey) + or (S.FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey) local badgeBg = CreateFrame("Frame", nil, header, "BackdropTemplate") badgeBg:SetHeight(16) @@ -6203,10 +6220,10 @@ CreateEffectCard = function(parent, yPos, effect) else local auraCfg = CurrentAuraPool()[effect.auraName] if auraCfg then auraCfg[effect.typeKey] = nil end - CleanupAdHocAura(effect.auraName) -- drop emptied ad-hoc "#" entries + S.CleanupAdHocAura(effect.auraName) -- drop emptied ad-hoc "#" entries end expandedCards[cardKey] = nil - SwitchTab("effects") + S.SwitchTab("effects") RefreshPlacedIndicators() RefreshPreviewEffects() -- Structural change: the container must rebuild AND the buff-row @@ -6267,7 +6284,7 @@ CreateEffectCard = function(parent, yPos, effect) -- Structural change: the factory must tear down / stand up the -- container and the buff-row dedup union changes (hidden = not -- tracked), so run the full refresh path (mirror the enable toggle). - SwitchTab("effects") + S.SwitchTab("effects") RefreshPlacedIndicators() RefreshPreviewEffects() DF:InvalidateAuraLayout() @@ -6287,7 +6304,7 @@ CreateEffectCard = function(parent, yPos, effect) -- Header click → toggle expansion header:SetScript("OnClick", function() expandedCards[cardKey] = not expandedCards[cardKey] - SwitchTab("effects") + S.SwitchTab("effects") end) header:SetScript("OnEnter", function(self) self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) @@ -6315,7 +6332,7 @@ CreateEffectCard = function(parent, yPos, effect) end -- Build type-specific widgets (derive width from parent scroll frame) - local bodyWidth = (tabContentFrame and tabContentFrame:GetWidth() or 260) - 24 + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 if bodyWidth < 100 then bodyWidth = 240 end local triggersH = 0 @@ -6401,7 +6418,7 @@ CreateEffectCard = function(parent, yPos, effect) tone = "danger", onClick = function() RemoveFrameEffectTrigger(effect.auraName, effect.typeKey, capturedTrigName) - SwitchTab("effects") + S.SwitchTab("effects") RefreshPreviewEffects() end, }) @@ -6450,7 +6467,7 @@ CreateEffectCard = function(parent, yPos, effect) for _, t in ipairs(currentTriggers) do trigLookup[t] = true end OpenADPicker({ - title = format(L["Select trigger for %s"], FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey), + title = format(L["Select trigger for %s"], S.FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey), subtitle = effect.displayName, records = function() return BuildADPickerRecords(true) end, classLock = (not isOtherTrig) and trigSpecInfo and trigSpecInfo.class or nil, @@ -6462,7 +6479,7 @@ CreateEffectCard = function(parent, yPos, effect) handler = function(rec, _, picker) AddFrameEffectTrigger(effect.auraName, effect.typeKey, rec.auraName, capturedPool) picker:Close() - SwitchTab("effects") + S.SwitchTab("effects") RefreshPreviewEffects() end, }, @@ -6534,13 +6551,13 @@ CreateEffectCard = function(parent, yPos, effect) sharedBtn:SetScript("OnClick", function() local cfg = EnsureTypeConfig(effect.auraName, effect.typeKey) cfg.borderMode = nil -- shared is default - SwitchTab("effects") + S.SwitchTab("effects") RefreshPreviewEffects() end) customBtn:SetScript("OnClick", function() local cfg = EnsureTypeConfig(effect.auraName, effect.typeKey) cfg.borderMode = "custom" - SwitchTab("effects") + S.SwitchTab("effects") RefreshPreviewEffects() end) @@ -6636,7 +6653,7 @@ CreateEffectCard = function(parent, yPos, effect) end) collapseBar:SetScript("OnClick", function() expandedCards[cardKey] = false - SwitchTab("effects") + S.SwitchTab("effects") end) local contentH = (bodyH or 50) + triggersH + collapseBarH @@ -6649,9 +6666,9 @@ CreateEffectCard = function(parent, yPos, effect) end -- ── BUILD EFFECTS TAB ── -BuildEffectsTab = function() - if not tabContentFrame then return end - local parent = tabContentFrame +S.BuildEffectsTab = function() + if not S.tabContentFrame then return end + local parent = S.tabContentFrame local yPos = -10 local tc = GetThemeColor() @@ -6795,12 +6812,12 @@ BuildEffectsTab = function() -- Shared styling: standard hover + an active (selected) state marked by a -- prominent accent border. The row rebuilds on click, so set active here. GUI:StyleButton(chipBtn) - chipBtn:SetActive(activeFilter == chip.key) + chipBtn:SetActive(S.activeFilter == chip.key) local capturedKey = chip.key chipBtn:SetScript("OnClick", function() - activeFilter = capturedKey - SwitchTab("effects") + S.activeFilter = capturedKey + S.SwitchTab("effects") end) tinsert(chipBtns, chipBtn) @@ -6846,7 +6863,7 @@ BuildEffectsTab = function() -- Apply filter local filtered = {} for _, effect in ipairs(effects) do - if activeFilter == "all" or effect.typeKey == activeFilter then + if S.activeFilter == "all" or effect.typeKey == S.activeFilter then tinsert(filtered, effect) end end @@ -6861,16 +6878,16 @@ BuildEffectsTab = function() -- unsupported-spec message there. if not IsOtherTab() and (not spec or not specAuras or #specAuras == 0) then empty:SetText(L["No trackable spells found for this spec.\n\nYou can select a different spec using the dropdown above."]) - elseif activeFilter == "all" then + elseif S.activeFilter == "all" then empty:SetText(L["No effects configured yet.\nClick '+ Add Indicator' to get started."]) else - empty:SetText(format(L["No %s effects configured."], (PLACED_TYPE_LABELS[activeFilter] or FRAME_LEVEL_LABELS[activeFilter] or activeFilter))) + empty:SetText(format(L["No %s effects configured."], (S.PLACED_TYPE_LABELS[S.activeFilter] or S.FRAME_LEVEL_LABELS[S.activeFilter] or S.activeFilter))) end empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) empty:SetJustifyH("CENTER") else for _, effect in ipairs(filtered) do - yPos = CreateEffectCard(parent, yPos, effect) + yPos = S.CreateEffectCard(parent, yPos, effect) end end @@ -6879,9 +6896,9 @@ end -- ── BUILD GLOBAL TAB ── -- Wraps the existing BuildGlobalView into the tab content frame -BuildGlobalTab = function() - if not tabContentFrame then return end - BuildGlobalView(tabContentFrame) +S.BuildGlobalTab = function() + if not S.tabContentFrame then return end + BuildGlobalView(S.tabContentFrame) end -- ── BUILD LAYOUT GROUPS TAB ── @@ -6975,7 +6992,7 @@ local function AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) -- Visibility changes inside the section (border style dropdown swapping its -- widget set) re-measure heights, so rebuild the tab — the same full-rebuild -- the effect cards' dropdown callbacks run (AuraDesigner_RefreshPage). - local function rebuildTab() SwitchTab("layout") end + local function rebuildTab() S.SwitchTab("layout") end -- One collapsible box PER CATEGORY — the expanded effect card's section -- structure (Appearance / Border / Duration Text / Stack Count, same names @@ -7133,9 +7150,9 @@ local function AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) return by end -BuildLayoutGroupsTab = function() - if not tabContentFrame then return end - local parent = tabContentFrame +S.BuildLayoutGroupsTab = function() + if not S.tabContentFrame then return end + local parent = S.tabContentFrame local yPos = -10 local tc = GetThemeColor() @@ -7154,7 +7171,7 @@ BuildLayoutGroupsTab = function() local group = CreateLayoutGroup() if group then expandedGroups[GroupExpandKey(group.id)] = true - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() end end) @@ -7168,7 +7185,7 @@ BuildLayoutGroupsTab = function() local group = CreateLayoutGroup(nil, "filter") if group then expandedGroups[GroupExpandKey(group.id)] = true - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() end end) @@ -7258,7 +7275,7 @@ BuildLayoutGroupsTab = function() size = 22, onClick = function() DeleteLayoutGroup(capturedGroupID) - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() -- Deleting a group deletes its member indicators — same -- structural refresh as the effect-card delete / eye toggle. @@ -7297,7 +7314,7 @@ BuildLayoutGroupsTab = function() eyeBtn:SetScript("OnClick", function() group.enabled = (group.enabled == false) and true or false updateEyeIcon() - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() DF:InvalidateAuraLayout() DF:UpdateAllFrames() @@ -7313,7 +7330,7 @@ BuildLayoutGroupsTab = function() -- Header click → toggle expansion header:SetScript("OnClick", function() expandedGroups[expandKey] = not expandedGroups[expandKey] - SwitchTab("layout") + S.SwitchTab("layout") end) header:SetScript("OnEnter", function(self) self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) @@ -7333,7 +7350,7 @@ BuildLayoutGroupsTab = function() {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) local by = -10 - local bodyWidth = (tabContentFrame and tabContentFrame:GetWidth() or 260) - 24 + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 if bodyWidth < 100 then bodyWidth = 240 end -- Group Name (editable) @@ -7358,7 +7375,7 @@ BuildLayoutGroupsTab = function() group.name = val end self:ClearFocus() - SwitchTab("layout") + S.SwitchTab("layout") end) nameEdit:SetScript("OnEscapePressed", function(self) self:SetText(group.name) @@ -7380,7 +7397,7 @@ BuildLayoutGroupsTab = function() fsel.customs = fsel.customs or {} local function StructuralFilterRefresh() - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() DF:InvalidateAuraLayout() DF:UpdateAllFrames() @@ -7654,7 +7671,7 @@ BuildLayoutGroupsTab = function() -- Page content builds on first show (inside SelectTab), so -- the button reference exists by now. -- The add action is a row inside the Filter Designer's - -- scrolling left list, so the page scrolls it into view and + -- scrolling left list, so the S.page scrolls it into view and -- pulses it itself rather than handing back a bare widget. local fdPage = GUI.Pages["auras_filterdesigner"] if fdPage and fdPage._fdFocusNewFilter then @@ -7698,7 +7715,7 @@ BuildLayoutGroupsTab = function() upBtn:SetPoint("TOPLEFT", 2, -1) upBtn:SetScript("OnClick", function() SwapGroupMembers(capturedGroupID, capturedMi, capturedMi - 1) - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() -- Positions moved (member index feeds the grid) — re-arrange live frames. DF.AuraDesigner.Engine:ForceRefreshAllFrames() @@ -7712,7 +7729,7 @@ BuildLayoutGroupsTab = function() downBtn:SetPoint("BOTTOMLEFT", 2, 1) downBtn:SetScript("OnClick", function() SwapGroupMembers(capturedGroupID, capturedMi, capturedMi + 1) - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() -- Positions moved (member index feeds the grid) — re-arrange live frames. DF.AuraDesigner.Engine:ForceRefreshAllFrames() @@ -7758,7 +7775,7 @@ BuildLayoutGroupsTab = function() end end local mBadgeColor = BADGE_COLORS[memberType or "icon"] or BADGE_COLORS.icon - local mBadgeLabel = PLACED_TYPE_LABELS[memberType or "icon"] or "Icon" + local mBadgeLabel = S.PLACED_TYPE_LABELS[memberType or "icon"] or "Icon" local mBadge = CreateFrame("Frame", nil, memberRow, "BackdropTemplate") mBadge:SetHeight(16) @@ -7787,7 +7804,7 @@ BuildLayoutGroupsTab = function() RemoveGroupMember(capturedGroupID, capturedMember.auraName, capturedMember.indicatorID) -- Also delete the placed indicator itself RemoveIndicatorInstance(capturedMember.auraName, capturedMember.indicatorID) - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() RefreshPreviewEffects() -- Structural change: same full refresh as the @@ -7818,7 +7835,7 @@ BuildLayoutGroupsTab = function() local cardKey = "placed:" .. PoolKeyPrefix() .. capturedAuraName .. "#" .. capturedIndID wipe(expandedCards) expandedCards[cardKey] = true - activeTab = "effects" + S.activeTab = "effects" DF:AuraDesigner_RefreshPage() end) @@ -8007,7 +8024,7 @@ BuildLayoutGroupsTab = function() -- (an othersOnly group's spells keep their row icon). if isOtherGroups then local ooCb = GUI:CreateCheckbox(body, L["Others Only"], group, "othersOnly", function() - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() DF:InvalidateAuraLayout() DF:UpdateAllFrames() @@ -8055,9 +8072,9 @@ end -- prefix can never collide with them (the two id counters overlap). -- ============================================================ -BuildDebuffGroupsTab = function() - if not tabContentFrame then return end - local parent = tabContentFrame +S.BuildDebuffGroupsTab = function() + if not S.tabContentFrame then return end + local parent = S.tabContentFrame local yPos = -10 local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab accent @@ -8077,7 +8094,7 @@ BuildDebuffGroupsTab = function() -- checkboxes, dispel mode, hide-long controls, eye, delete, add): the -- claims union moves AND the card summary / grey states must rebuild. local function StructuralDebuffGroupRefresh() - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() DF:InvalidateAuraLayout() DF:UpdateAllFrames() @@ -8087,7 +8104,7 @@ BuildDebuffGroupsTab = function() end -- Same structural chain WITHOUT the tab rebuild — slider/dropdown-safe - -- (a SwitchTab from inside a slider callback destroys the widget + -- (a S.SwitchTab from inside a slider callback destroys the widget -- mid-interaction). Layout edits don't move the claims union, but the -- version bump keeps the factory's version-keyed dgroup record cache -- honest and costs one sig-gated re-resolve (offsets → ApplyStyle, @@ -8225,7 +8242,7 @@ BuildDebuffGroupsTab = function() -- Header click → toggle expansion header:SetScript("OnClick", function() expandedGroups[cardKey] = not expandedGroups[cardKey] - SwitchTab("layout") + S.SwitchTab("layout") end) header:SetScript("OnEnter", function(self) self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) @@ -8245,7 +8262,7 @@ BuildDebuffGroupsTab = function() {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) local by = -10 - local bodyWidth = (tabContentFrame and tabContentFrame:GetWidth() or 260) - 24 + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 if bodyWidth < 100 then bodyWidth = 240 end -- Repair a missing selection table (hand-edited data). All @@ -8281,7 +8298,7 @@ BuildDebuffGroupsTab = function() end self:ClearFocus() -- Name feeds no sig — cosmetic only (header + canvas label). - SwitchTab("layout") + S.SwitchTab("layout") RefreshPlacedIndicators() end) nameEdit:SetScript("OnEscapePressed", function(self) @@ -8465,64 +8482,62 @@ end -- ============================================================ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) - local prevDB = db -- capture before overwrite to detect mode switch - GUI = guiRef - page = pageRef - db = dbRef - Adapter = DF.AuraDesigner.Adapter + local prevDB = S.db -- capture before overwrite to detect mode switch + S.page = pageRef + S.db = dbRef - local parent = page.child + local parent = S.page.child -- ======================================== - -- REUSE: If mainFrame already exists, db hasn't changed (same mode) AND the + -- REUSE: If S.mainFrame already exists, S.db hasn't changed (same mode) AND the -- frame dimensions are unchanged, just re-parent, show, and refresh. A mode - -- switch (Party↔Raid) changes db; an auto-layout switch keeps the SAME db + -- switch (Party↔Raid) changes S.db; an auto-layout switch keeps the SAME S.db -- reference but changes frameWidth/Height — both must force a full rebuild so -- the preview mock resizes to the active layout's frame size. -- ======================================== local _adFDB = (DF.GetDB and DF:GetDB((GUI and GUI.SelectedMode) or "party")) or {} local _adW, _adH = _adFDB.frameWidth or 125, _adFDB.frameHeight or 64 - -- Auto-layout identity: two raid layouts share the SAME db proxy and may share + -- Auto-layout identity: two raid layouts share the SAME S.db proxy and may share -- frame dimensions, so neither check below distinguishes them — without this, - -- switching between same-size raid layouts reuses the stale page. + -- switching between same-size raid layouts reuses the stale S.page. local _adLayout = (DF.AutoProfilesUI and (DF.AutoProfilesUI.editingProfile or DF.AutoProfilesUI.activeRuntimeProfile)) or nil - -- Preset identity: switching the mode's preset keeps the same db/size/layout, - -- so without this the stale page (bound to the old preset) would be reused. + -- Preset identity: switching the mode's preset keeps the same S.db/size/layout, + -- so without this the stale S.page (bound to the old preset) would be reused. local _adPreset = DF.GetModeDesignerPresetName and DF:GetModeDesignerPresetName("aura", (GUI and GUI.SelectedMode) or "party") -- Editing identity: entering edit of the ACTIVE layout keeps the same table -- object (editingProfile == activeRuntimeProfile), so _adLayout alone -- misses the transition and the editing-banner offset is never applied. local _adEditing = (DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing()) or false - if mainFrame and prevDB == dbRef - and mainFrame.dfBuiltFrameW == _adW and mainFrame.dfBuiltFrameH == _adH - and mainFrame.dfBuiltLayout == _adLayout - and mainFrame.dfBuiltPreset == _adPreset - and mainFrame.dfBuiltEditing == _adEditing then - mainFrame:SetParent(parent) - mainFrame:SetAllPoints() - mainFrame:Show() + if S.mainFrame and prevDB == dbRef + and S.mainFrame.dfBuiltFrameW == _adW and S.mainFrame.dfBuiltFrameH == _adH + and S.mainFrame.dfBuiltLayout == _adLayout + and S.mainFrame.dfBuiltPreset == _adPreset + and S.mainFrame.dfBuiltEditing == _adEditing then + S.mainFrame:SetParent(parent) + S.mainFrame:SetAllPoints() + S.mainFrame:Show() DF:AuraDesigner_RefreshPage() return end -- Full build (first time, or mode switch) - if mainFrame then - mainFrame:Hide() - mainFrame:SetParent(nil) + if S.mainFrame then + S.mainFrame:Hide() + S.mainFrame:SetParent(nil) end wipe(placedIndicators) wipe(expandedCards) wipe(effectCardPool) - activeTab = "effects" - activeBuffTab = "my" - activeFilter = "all" - -- A shared picker left open on the OLD rightPanel dies with it (its + S.activeTab = "effects" + S.activeBuffTab = "my" + S.activeFilter = "all" + -- A shared picker left open on the OLD S.rightPanel dies with it (its -- close hook may already have run via the ancestor hide); drop the -- handle so CloseADPicker can't poke a stale overlay. - adPickerDirty = false - adPickerHandle = nil + S.adPickerDirty = false + S.adPickerHandle = nil -- Layout constants local BANNER_H = 68 @@ -8532,44 +8547,44 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- ======================================== -- MAIN FRAME -- ======================================== - mainFrame = CreateFrame("Frame", nil, parent) - mainFrame:SetAllPoints() + S.mainFrame = CreateFrame("Frame", nil, parent) + S.mainFrame:SetAllPoints() -- Record the frame dims this build was made for, so the reuse-guard above can - -- detect an auto-layout switch (same db, different frameWidth/Height) and rebuild. - mainFrame.dfBuiltFrameW = _adW - mainFrame.dfBuiltFrameH = _adH - mainFrame.dfBuiltLayout = _adLayout - mainFrame.dfBuiltPreset = _adPreset - mainFrame.dfBuiltEditing = _adEditing - -- Closing the settings window (or leaving this page) hides mainFrame with + -- detect an auto-layout switch (same S.db, different frameWidth/Height) and rebuild. + S.mainFrame.dfBuiltFrameW = _adW + S.mainFrame.dfBuiltFrameH = _adH + S.mainFrame.dfBuiltLayout = _adLayout + S.mainFrame.dfBuiltPreset = _adPreset + S.mainFrame.dfBuiltEditing = _adEditing + -- Closing the settings window (or leaving this S.page) hides S.mainFrame with -- no refresh pass, which would leave the rendered preview pool's border -- animations ticking on the external driver (it ticks hidden secretRect -- borders — see ClearPlacedIndicators). OnHide fires on effective-visibility - -- loss, so an ancestor hide (window close, page switch) reaches it too; the + -- loss, so an ancestor hide (window close, S.page switch) reaches it too; the -- reuse path re-renders via AuraDesigner_RefreshPage → RefreshPlacedIndicators - -- on return. mainFrame is created fresh per full build (the old one is hidden + -- on return. S.mainFrame is created fresh per full build (the old one is hidden -- and unparented above), so this hook lands exactly once per frame. - mainFrame:HookScript("OnHide", ClearPlacedIndicators) + S.mainFrame:HookScript("OnHide", ClearPlacedIndicators) -- Override RefreshStates: Aura Designer uses its own layout system. -- -- This hook gets called by anything that walks the GUI parent chain - -- looking for a page with RefreshStates+children — including + -- looking for a S.page with RefreshStates+children — including -- CreateInfoBanner's TriggerHostRelayout after every measure cycle. -- AuraDesigner_RefreshPage is a heavyweight rebuild (destroys + -- recreates every effect card on the active tab), so firing it -- from a banner's auto-resize cascade meant: each new banner from - -- BuildEffectsTab triggered SetText → schedule DoRecomputeHeight → - -- TriggerHostRelayout → page:RefreshStates → AuraDesigner_RefreshPage - -- → SwitchTab → BuildEffectsTab → create more banners → repeat at + -- S.BuildEffectsTab triggered SetText → schedule DoRecomputeHeight → + -- TriggerHostRelayout → S.page:RefreshStates → AuraDesigner_RefreshPage + -- → S.SwitchTab → S.BuildEffectsTab → create more banners → repeat at -- ~9 Hz, locking up the GUI the moment the perf-warning banner -- surfaced (because picking an animation triggered the chain). -- - -- The fix: only call AuraDesigner_RefreshPage when the page + -- The fix: only call AuraDesigner_RefreshPage when the S.page -- dimensions actually changed. GUI window resize cases (the real -- reason this hook exists) still rebuild; banner-cascade-as-noop -- cases stop the loop. - page.RefreshStates = function(self) + S.page.RefreshStates = function(self) local pageH = self:GetHeight() self.child:SetHeight(pageH) local newW = GUI.contentFrame and (GUI.contentFrame:GetWidth() - 30) or nil @@ -8588,9 +8603,9 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- Same-size revisit AFTER the OnHide hook cleared the canvas (tab -- away + back re-enters ONLY through RefreshCached -> here; the -- builder is cache-skipped). Repaint the pooled slots without the - -- full page rebuild: slot restyling creates no banners, so the + -- full S.page rebuild: slot restyling creates no banners, so the -- banner-cascade loop this early-out exists for cannot re-arm. - if placedCleared then + if S.placedCleared then RefreshPlacedIndicators() RefreshPreviewEffects() end @@ -8603,7 +8618,7 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) local yPos = -8 -- top gap; kept equal to the Text Designer's _tdTopY for a consistent header gap -- While editing a raid auto-layout, the AutoProfiles editing banner is a ~50px - -- overlay anchored to the top of the content frame; this custom AD page lays + -- overlay anchored to the top of the content frame; this custom AD S.page lays -- its own content out from the top too, so push everything down to clear it -- (otherwise the editing banner sits on top of the enable banner / preset bar). if DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() then @@ -8613,12 +8628,12 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- ======================================== -- ENABLE BANNER (full width) -- ======================================== - enableBanner = CreateEnableBanner(mainFrame) - enableBanner:SetPoint("TOPLEFT", mainFrame, "TOPLEFT", 0, yPos) - enableBanner:SetPoint("TOPRIGHT", mainFrame, "TOPRIGHT", 0, yPos) + S.enableBanner = CreateEnableBanner(S.mainFrame) + S.enableBanner:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) + S.enableBanner:SetPoint("TOPRIGHT", S.mainFrame, "TOPRIGHT", 0, yPos) - -- No Copy / Sync pair here (every other mode-specific page has one). The one - -- key this page owns is the template NAME, and the template bar below sets + -- No Copy / Sync pair here (every other mode-specific S.page has one). The one + -- key this S.page owns is the template NAME, and the template bar below sets -- it directly — so Copy was "pick that name in the other tab" and Sync was a -- link that could only ever hold one name in step. Sharing is now stated and -- undone on the bar itself; see GUI:CreateDesignerPresetBar. @@ -8626,10 +8641,10 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- ======================================== -- PRESET BAR (which named preset this mode uses + library management) -- Rides row 2 of the enable banner (left side; the spec dropdown holds the - -- right side). Compact icon buttons keep the row within the page width. + -- right side). Compact icon buttons keep the row within the S.page width. -- ======================================== if GUI.CreateDesignerPresetBar then - local presetBar = GUI:CreateDesignerPresetBar(enableBanner, { + local presetBar = GUI:CreateDesignerPresetBar(S.enableBanner, { kind = "aura", iconButtons = true, getMode = function() return (GUI and GUI.SelectedMode) or "party" end, @@ -8640,7 +8655,7 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- own click handler. if C_Timer and C_Timer.After then C_Timer.After(0, function() - if DF.BuildAuraDesignerPage then DF.BuildAuraDesignerPage(GUI, page, db) end + if DF.BuildAuraDesignerPage then DF.BuildAuraDesignerPage(GUI, S.page, S.db) end DF:InvalidateAuraLayout() DF:UpdateAllFrames() local E = DF.AuraDesigner and DF.AuraDesigner.Engine @@ -8651,9 +8666,9 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) }) -- Row 2 reflows (B2): the spec dropdown moved onto the tab strip, so -- the preset bar takes the whole row. - presetBar:SetPoint("LEFT", enableBanner, "LEFT", 10, -18) - presetBar:SetPoint("RIGHT", enableBanner, "RIGHT", -10, -18) - enableBanner.presetBar = presetBar + presetBar:SetPoint("LEFT", S.enableBanner, "LEFT", 10, -18) + presetBar:SetPoint("RIGHT", S.enableBanner, "RIGHT", -10, -18) + S.enableBanner.presetBar = presetBar end yPos = yPos - (BANNER_H + SECTION_GAP) @@ -8665,10 +8680,10 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- relocated spec dropdown on the strip's right end (per the prototype — -- the spec only applies to My Buffs). -- ======================================== - local buffTabBar = CreateFrame("Frame", nil, mainFrame) + local buffTabBar = CreateFrame("Frame", nil, S.mainFrame) buffTabBar:SetHeight(BUFFTAB_H) - buffTabBar:SetPoint("TOPLEFT", mainFrame, "TOPLEFT", 0, yPos) - buffTabBar:SetPoint("TOPRIGHT", mainFrame, "TOPRIGHT", 0, yPos) + buffTabBar:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) + buffTabBar:SetPoint("TOPRIGHT", S.mainFrame, "TOPRIGHT", 0, yPos) -- The three pools differ on two axes the labels can't carry — WHOSE casts -- count, and whether the pool is per-spec — so each tab explains itself on @@ -8711,7 +8726,7 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) GUI:ShowTooltip(self, { title = tipTitle, lines = tipLines }) end) btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - btn:SetActive(activeBuffTab == def.key) + btn:SetActive(S.activeBuffTab == def.key) mainTabButtons[def.key] = btn prevMainBtn = btn end @@ -8720,10 +8735,10 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) local stripSpecLabel = buffTabBar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") stripSpecLabel:SetText(L["Spec:"]) stripSpecLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - specDropdown, specDropdownUpdate = CreateSpecDropdown(buffTabBar) - specDropdown:SetSize(165, 22) - specDropdown:SetPoint("RIGHT", buffTabBar, "RIGHT", -5, 0) - stripSpecLabel:SetPoint("RIGHT", specDropdown, "LEFT", -4, 0) + S.specDropdown, S.specDropdownUpdate = CreateSpecDropdown(buffTabBar) + S.specDropdown:SetSize(165, 22) + S.specDropdown:SetPoint("RIGHT", buffTabBar, "RIGHT", -5, 0) + stripSpecLabel:SetPoint("RIGHT", S.specDropdown, "LEFT", -4, 0) UpdateSpecDropdownState() yPos = yPos - (BUFFTAB_H + SECTION_GAP) @@ -8731,44 +8746,44 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- ======================================== -- 50/50 SPLIT: LEFT PANEL + RIGHT PANEL -- ======================================== - local splitContainer = CreateFrame("Frame", nil, mainFrame) - splitContainer:SetPoint("TOPLEFT", mainFrame, "TOPLEFT", 0, yPos) - splitContainer:SetPoint("BOTTOMRIGHT", mainFrame, "BOTTOMRIGHT", 0, 0) - mainFrame.splitContainer = splitContainer + local splitContainer = CreateFrame("Frame", nil, S.mainFrame) + splitContainer:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) + splitContainer:SetPoint("BOTTOMRIGHT", S.mainFrame, "BOTTOMRIGHT", 0, 0) + S.mainFrame.splitContainer = splitContainer -- ── LEFT PANEL (frame preview) ── - leftPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") - leftPanel:SetPoint("TOPLEFT", 0, 0) - leftPanel:SetPoint("BOTTOMLEFT", 0, 0) - leftPanel:SetPoint("RIGHT", splitContainer, "CENTER", -3, 0) + S.leftPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") + S.leftPanel:SetPoint("TOPLEFT", 0, 0) + S.leftPanel:SetPoint("BOTTOMLEFT", 0, 0) + S.leftPanel:SetPoint("RIGHT", splitContainer, "CENTER", -3, 0) -- NO border here — the inner preview container (CreateFramePreview) draws the -- visible dim border. A border on both stacked to a brighter doubled line. - GUI:CreatePanelBackdrop(leftPanel, {border = false}) + GUI:CreatePanelBackdrop(S.leftPanel, {border = false}) -- Frame preview (reuses existing CreateFramePreview with adapted anchoring) - origY_framePreview = 0 - framePreview = CreateFramePreview(leftPanel, 0, nil) - contentRightInset = 0 -- No right inset needed in new layout + S.origY_framePreview = 0 + S.framePreview = CreateFramePreview(S.leftPanel, 0, nil) + S.contentRightInset = 0 -- No right inset needed in new layout -- ── RIGHT PANEL (tabbed settings) ── - rightPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") - rightPanel:SetPoint("TOPRIGHT", 0, 0) - rightPanel:SetPoint("BOTTOMRIGHT", 0, 0) - rightPanel:SetPoint("LEFT", splitContainer, "CENTER", 3, 0) -- 6px split gap (matches Text Designer) - GUI:CreatePanelBackdrop(rightPanel, {borderColor = {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}}) + S.rightPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") + S.rightPanel:SetPoint("TOPRIGHT", 0, 0) + S.rightPanel:SetPoint("BOTTOMRIGHT", 0, 0) + S.rightPanel:SetPoint("LEFT", splitContainer, "CENTER", 3, 0) -- 6px split gap (matches Text Designer) + GUI:CreatePanelBackdrop(S.rightPanel, {borderColor = {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}}) -- ── TAB BAR ── (shared underline-tab style, mirroring the Pinned Frames -- tabs: a transparent strip with a baseline; each tab is a StyleButton in -- `tab` mode — faint cell when inactive, accent fill + underline + accent -- label when active. Per-tab accent preserves each section's identity.) - tabBar = CreateFrame("Frame", nil, rightPanel, "BackdropTemplate") - tabBar:SetHeight(28) + S.tabBar = CreateFrame("Frame", nil, S.rightPanel, "BackdropTemplate") + S.tabBar:SetHeight(28) -- Inset just inside the panel's border so the tabs don't overlap/overrun it. - tabBar:SetPoint("TOPLEFT", 4, -4) - tabBar:SetPoint("TOPRIGHT", -4, -4) + S.tabBar:SetPoint("TOPLEFT", 4, -4) + S.tabBar:SetPoint("TOPRIGHT", -4, -4) -- Baseline under the whole strip; the active tab's underline sits on it. - local tabBaseline = tabBar:CreateTexture(nil, "ARTWORK") + local tabBaseline = S.tabBar:CreateTexture(nil, "ARTWORK") tabBaseline:SetTexture("Interface\\Buttons\\WHITE8x8") tabBaseline:SetHeight(1) tabBaseline:SetPoint("BOTTOMLEFT", 0, 0) @@ -8784,7 +8799,7 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) wipe(tabButtons) for i, def in ipairs(TAB_DEFS) do - local btn = CreateFrame("Button", nil, tabBar, "BackdropTemplate") + local btn = CreateFrame("Button", nil, S.tabBar, "BackdropTemplate") btn:SetHeight(28) if i == 1 then btn:SetPoint("TOPLEFT", 0, 0) @@ -8796,14 +8811,14 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) if provW < 100 then provW = 600 end btn:SetWidth(max(60, floor(((provW / 2) - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS))) - -- Shared underline-tab styling; SwitchTab drives SetActive. label = btn.Text. + -- Shared underline-tab styling; S.SwitchTab drives SetActive. label = btn.Text. GUI:StyleButton(btn, { tab = true, text = def.label, accent = def.accent, font = "DFFontHighlight" }) btn.label = btn.Text btn.tabKey = def.key btn:SetScript("OnClick", function(self) if self.dfDisabled then return end -- frosted (Effects on Debuffs) - SwitchTab(self.tabKey) + S.SwitchTab(self.tabKey) end) -- Effects is buff-pool-only (C2): frosted on the Debuffs tab — @@ -8825,7 +8840,7 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) UpdateLayoutTabState() -- Equal-width tabs (accounting for the gaps) on parent resize. - tabBar:SetScript("OnSizeChanged", function(self, w, h) + S.tabBar:SetScript("OnSizeChanged", function(self, w, h) local n = #TAB_DEFS local tabW = (w - (n - 1) * TAB_GAP) / n for _, def in ipairs(TAB_DEFS) do @@ -8835,35 +8850,35 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) end) -- ── TAB CONTENT (scrollable) ── - tabScrollFrame = CreateFrame("ScrollFrame", nil, rightPanel, "ScrollFrameTemplate") - tabScrollFrame:SetPoint("TOPLEFT", tabBar, "BOTTOMLEFT", 0, 0) - tabScrollFrame:SetPoint("BOTTOMRIGHT", -22, 0) + S.tabScrollFrame = CreateFrame("ScrollFrame", nil, S.rightPanel, "ScrollFrameTemplate") + S.tabScrollFrame:SetPoint("TOPLEFT", S.tabBar, "BOTTOMLEFT", 0, 0) + S.tabScrollFrame:SetPoint("BOTTOMRIGHT", -22, 0) - tabContentFrame = CreateFrame("Frame", nil, tabScrollFrame) - -- Pre-compute initial width from parent geometry so SwitchTab() has + S.tabContentFrame = CreateFrame("Frame", nil, S.tabScrollFrame) + -- Pre-compute initial width from parent geometry so S.SwitchTab() has -- accurate dimensions before the first layout pass fires OnSizeChanged. local earlyW = parent:GetWidth() if earlyW < 100 then earlyW = (GUI.contentFrame and GUI.contentFrame:GetWidth() or 600) - 30 end - tabContentFrame:SetWidth(max(1, (earlyW / 2) - 2 - 22)) - tabContentFrame:SetHeight(800) - tabScrollFrame:SetScrollChild(tabContentFrame) - DF.GUI.StyleScrollBar(tabScrollFrame) + S.tabContentFrame:SetWidth(max(1, (earlyW / 2) - 2 - 22)) + S.tabContentFrame:SetHeight(800) + S.tabScrollFrame:SetScrollChild(S.tabContentFrame) + DF.GUI.StyleScrollBar(S.tabScrollFrame) -- Match scroll child width to scroll frame - tabScrollFrame:SetScript("OnSizeChanged", function(self, w, h) - tabContentFrame:SetWidth(w) + S.tabScrollFrame:SetScript("OnSizeChanged", function(self, w, h) + S.tabContentFrame:SetWidth(w) end) -- Smooth scroll local SCROLL_STEP = 30 - tabScrollFrame:SetScript("OnMouseWheel", function(self, delta) + S.tabScrollFrame:SetScript("OnMouseWheel", function(self, delta) local current = self:GetVerticalScroll() local maxScroll = max(0, self:GetVerticalScrollRange()) local newScroll = max(0, min(maxScroll, current - (delta * SCROLL_STEP))) self:SetVerticalScroll(newScroll) end) - tabContentFrame:EnableMouseWheel(true) - tabContentFrame:SetScript("OnMouseWheel", function(self, delta) + S.tabContentFrame:EnableMouseWheel(true) + S.tabContentFrame:SetScript("OnMouseWheel", function(self, delta) local p = self:GetParent() if p and p:GetScript("OnMouseWheel") then p:GetScript("OnMouseWheel")(p, delta) @@ -8875,9 +8890,9 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- ======================================== -- Force initial width sync: OnSizeChanged won't fire until the frame renders, - -- but SwitchTab needs accurate widths now for slider/dropdown sizing. + -- but S.SwitchTab needs accurate widths now for slider/dropdown sizing. -- Compute initial scroll content width from parent geometry. - -- rightPanel:GetWidth() returns 0 before the first layout pass, so we + -- S.rightPanel:GetWidth() returns 0 before the first layout pass, so we -- calculate from the parent which already has valid geometry on a mode -- switch (Party↔Raid). local parentW = parent:GetWidth() @@ -8885,13 +8900,13 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) if parentW < 100 then parentW = UIParent:GetWidth() / 2 end local initW = (parentW / 2) - 2 - 22 -- half split minus gap minus scrollbar if initW > 50 then - tabContentFrame:SetWidth(initW) + S.tabContentFrame:SetWidth(initW) end - SwitchTab("effects") + S.SwitchTab("effects") C_Timer.After(0, function() - if tabBar and tabBar:IsVisible() and tabBar:GetWidth() > 10 then - local tabW = (tabBar:GetWidth() - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS + if S.tabBar and S.tabBar:IsVisible() and S.tabBar:GetWidth() > 10 then + local tabW = (S.tabBar:GetWidth() - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS for _, def in ipairs(TAB_DEFS) do if tabButtons[def.key] then tabButtons[def.key]:SetWidth(tabW) @@ -8908,41 +8923,41 @@ end -- ============================================================ function DF:AuraDesigner_RefreshPage() - if not mainFrame then return end + if not S.mainFrame then return end -- Account for editing banner offset (50px) when editing an auto layout local editingOffset = 0 if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then editingOffset = 50 end - mainFrame:ClearAllPoints() - mainFrame:SetPoint("TOPLEFT", mainFrame:GetParent(), "TOPLEFT", 0, -editingOffset) - mainFrame:SetPoint("BOTTOMRIGHT", mainFrame:GetParent(), "BOTTOMRIGHT", 0, 0) + S.mainFrame:ClearAllPoints() + S.mainFrame:SetPoint("TOPLEFT", S.mainFrame:GetParent(), "TOPLEFT", 0, -editingOffset) + S.mainFrame:SetPoint("BOTTOMRIGHT", S.mainFrame:GetParent(), "BOTTOMRIGHT", 0, 0) -- Check if spec changed local currentSpec = ResolveSpec() - if currentSpec ~= selectedSpec then - selectedSpec = currentSpec + if currentSpec ~= S.selectedSpec then + S.selectedSpec = currentSpec end -- Subtle class-color hint on the preview border, dimmed to 0.5 alpha so it -- stays as quiet as the Text Designer's neutral border (just tinted to the -- spec). Was previously full alpha = the harsh "white line" (white for Priest). - if framePreview then - local resolvedSpec = currentSpec or selectedSpec + if S.framePreview then + local resolvedSpec = currentSpec or S.selectedSpec local specInfoEntry = resolvedSpec and DF.AuraDesigner.SpecInfo[resolvedSpec] local classToken = specInfoEntry and specInfoEntry.class local classColor = classToken and RAID_CLASS_COLORS[classToken] if classColor then - framePreview:SetBackdropBorderColor(classColor.r, classColor.g, classColor.b, 0.5) + S.framePreview:SetBackdropBorderColor(classColor.r, classColor.g, classColor.b, 0.5) else - framePreview:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + S.framePreview:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) end end -- Rebuild the current tab to reflect data changes - if activeTab and SwitchTab then - SwitchTab(activeTab) + if S.activeTab and S.SwitchTab then + S.SwitchTab(S.activeTab) end -- Refresh frame preview @@ -8950,30 +8965,30 @@ function DF:AuraDesigner_RefreshPage() RefreshPreviewEffects() -- Update enable state - if enableBanner then - enableBanner.checkbox:SetChecked(GetAuraDesignerDB().enabled) + if S.enableBanner then + S.enableBanner.checkbox:SetChecked(GetAuraDesignerDB().enabled) end -- Spec dropdown lives on the main tab strip (B2): refresh its text on -- My Buffs / keep the greyed "shared across specs" caption on Other Buffs. UpdateSpecDropdownState() -- Show/hide disabled overlay on the split container - if mainFrame.splitContainer then + if S.mainFrame.splitContainer then local adEnabled = GetAuraDesignerDB().enabled if not adEnabled then - if not mainFrame.disabledOverlay then - -- Shared with the Text Designer and Raid Auto Layouts; this page + if not S.mainFrame.disabledOverlay then + -- Shared with the Text Designer and Raid Auto Layouts; this S.page -- only owns the extent (the whole split container) and the label. - local overlay = GUI:CreateDisabledOverlay(mainFrame.splitContainer, { + local overlay = GUI:CreateDisabledOverlay(S.mainFrame.splitContainer, { label = L["Aura Designer is disabled"], }) overlay:SetAllPoints() - mainFrame.disabledOverlay = overlay + S.mainFrame.disabledOverlay = overlay end - mainFrame.disabledOverlay:Show() + S.mainFrame.disabledOverlay:Show() else - if mainFrame.disabledOverlay then - mainFrame.disabledOverlay:Hide() + if S.mainFrame.disabledOverlay then + S.mainFrame.disabledOverlay:Hide() end end end @@ -8987,7 +9002,7 @@ function DF:AuraDesigner_RefreshPage() -- 12.1: the native factory buff row DERIVES its Aura-Designer dedup set from the AD -- config at build time. Indicator add/remove (and other config mutations) funnel -- through this refresh but do NOT otherwise re-drive live frames, so poke the buff - -- row here — mirror the aura-blacklist page (InvalidateAuraLayout -> RefreshFactoryRows). + -- row here — mirror the aura-blacklist S.page (InvalidateAuraLayout -> RefreshFactoryRows). -- DriveBuffFactory rebuilds only when the excluded set actually moved (sig-gated), so a -- navigation-only refresh is a cheap no-op. Factory-gated so the pre-12.1 path is untouched. if DF.AuraContainer and DF.AuraContainer.IsSupported and DF.AuraContainer.IsSupported() From 8501daf52590971094eb212629fd49d3ab1df948 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 17:56:45 +0100 Subject: [PATCH 06/34] AuraDesigner editor: publish cross-cut private helpers on _priv Behaviour-preserving prep, pure addition (85 lines, 0 deletions). A `local function` is invisible to any other file, so a helper called far from its declaration pins this file together. This editor is densely coupled: 75 read-only helpers reach across the four chosen split boundaries -- GetAuraDesignerDB, CreateCardShell, ResolveSpec, CurrentAuraPool, TYPE_DEFAULTS, RefreshPlacedIndicators and the rest. Each is published immediately after its own definition, so the publish always lands in the same part as the thing it publishes. Sibling parts re-declare `local X = P.X` -- the same object, so no call site changes. Cheap boundaries only exist below line ~750 (2 crossers); by line 3733 it is 59. Splitting the bulk was never going to be free, and this is the cost. --- AuraDesigner/UI/Options.lua | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/AuraDesigner/UI/Options.lua b/AuraDesigner/UI/Options.lua index a6b983ca..cc1690db 100644 --- a/AuraDesigner/UI/Options.lua +++ b/AuraDesigner/UI/Options.lua @@ -26,9 +26,19 @@ local L = DF.L -- state via `local S = DF.AuraDesigner._uiState`. -- GUI and Adapter are NOT here: both are load-time constants (DF.GUI and -- DF.AuraDesigner.Adapter), so each part can simply re-declare them. +-- Shared private helpers. +-- A `local function` is invisible to any other file, so a helper called far +-- from its declaration pins this file together -- it cannot be split while a +-- later section still calls it. This editor is densely coupled: 75 helpers +-- reach across the chosen boundaries. Publishing them lets each part +-- re-declare `local X = P.X` -- the same function object, so no call site +-- changes and behaviour is identical. +-- Private by convention -- nothing outside AuraDesigner/ should touch this. +local P = {} local S = {} DF.AuraDesigner = DF.AuraDesigner or {} DF.AuraDesigner._uiState = S +DF.AuraDesigner._priv = P -- Load-time constants. Both used to be assigned inside BuildAuraDesignerPage, -- which made them mutable and so un-splittable. Neither ever actually varies: @@ -65,6 +75,7 @@ local C_TEXT_DIM = DF.GUI.Colors.textDim -- One namespace table instead of one local per option table (the main chunk -- rides the Lua 5.1 200-locals ceiling; seven locals reclaimed to one). local OPTS = {} +P.OPTS = OPTS local function RefreshLocaleStrings() OPTS.INDICATOR_TYPES = { @@ -746,10 +757,12 @@ local function GetAuraDesignerDB() MigrateDefaultRefreshLazy(adDB) return adDB end +P.GetAuraDesignerDB = GetAuraDesignerDB local function GetThemeColor() return GUI.GetThemeColor() end +P.GetThemeColor = GetThemeColor local function ApplyBackdrop(frame, bgColor, borderColor) -- Build through the shared GUI backdrop once per frame, then push only @@ -778,6 +791,7 @@ local function ApplyBackdrop(frame, bgColor, borderColor) end end end +P.ApplyBackdrop = ApplyBackdrop -- ============================================================ -- COLLAPSIBLE CARD SHELL @@ -815,6 +829,7 @@ local function CreateCardShell(parent, opts) return card, header, chevron end +P.CreateCardShell = CreateCardShell -- ============================================================ -- BUFF COEXISTENCE POPUP @@ -896,6 +911,7 @@ local function ShowBuffCoexistPopup(onConfirm, onCancel) f:Show() end +P.ShowBuffCoexistPopup = ShowBuffCoexistPopup -- Get or resolve the active spec key from settings local function ResolveSpec() @@ -905,6 +921,7 @@ local function ResolveSpec() end return adDB.spec end +P.ResolveSpec = ResolveSpec -- Track which spec aura tables have already been sanitized this session local sanitizedSpecAuras = {} @@ -938,6 +955,7 @@ local function GetSpecAuras(spec) end return specAuras end +P.GetSpecAuras = GetSpecAuras -- Returns the spec-INDEPENDENT "Other Buffs" pool (B1): a flat map auraName -> auraCfg -- with the EXACT record shape of adDB.auras[spec] entries (indicators array, frame-level @@ -974,6 +992,7 @@ local function GetOtherAuras() end return otherAuras end +P.GetOtherAuras = GetOtherAuras -- Returns the preset's DEBUFF CATEGORY GROUPS array (C1): a flat, spec-INDEPENDENT -- array of group records (mirror of the otherAuras siblings-not-pseudo-spec rule — @@ -1031,6 +1050,7 @@ local function NextGroupName(groups, prefix) end return prefix .. " " .. (highest + 1) end +P.NextGroupName = NextGroupName -- Create a new debuff category group (C1 data model; C2 wires the UI). Defaults: -- Boss + Role selected (the classic "important debuffs" baseline), everything @@ -1063,6 +1083,7 @@ local function CreateDebuffGroup(name) tinsert(groups, group) return group end +P.CreateDebuffGroup = CreateDebuffGroup -- Returns the spec-scoped layout groups array, creating it if needed local function GetSpecLayoutGroups(spec) @@ -1074,6 +1095,7 @@ local function GetSpecLayoutGroups(spec) if not adDB.layoutGroups[spec] then adDB.layoutGroups[spec] = {} end return adDB.layoutGroups[spec] end +P.GetSpecLayoutGroups = GetSpecLayoutGroups -- ============================================================ -- MAIN POOL TABS (B2) — My Buffs / Other Buffs @@ -1089,6 +1111,7 @@ S.activeBuffTab = "my" -- "my" | "debuffs" | "other" local function IsOtherTab() return S.activeBuffTab == "other" end +P.IsOtherTab = IsOtherTab -- C2: the Debuffs tab hosts debuff CATEGORY groups (spec-independent, no -- spell pool, no placed indicators). Its Effects sub-tab frosts, the spec @@ -1096,11 +1119,13 @@ end local function IsDebuffTab() return S.activeBuffTab == "debuffs" end +P.IsDebuffTab = IsDebuffTab -- Read-only placeholder returned while the other pool doesn't exist yet. -- Merely VISITING the Other Buffs tab must not create adDB.otherAuras — -- only the first ADD does (via CurrentAuraPoolWrite). Never written. local EMPTY_POOL = {} +P.EMPTY_POOL = EMPTY_POOL -- READ access to the active tab's pool. Never creates adDB.otherAuras. -- `spec` is forwarded to GetSpecAuras on the My Buffs tab only. @@ -1116,6 +1141,7 @@ local function CurrentAuraPool(spec) if S.activeBuffTab == "debuffs" then return EMPTY_POOL end return GetSpecAuras(spec) end +P.CurrentAuraPool = CurrentAuraPool -- WRITE access: creates the pool table (the other pool is born lazily on -- the first add — drag-drop, picker click, or add-by-ID). @@ -1131,6 +1157,7 @@ end local function PoolKeyPrefix() return (S.activeBuffTab == "other") and "other:" or "" end +P.PoolKeyPrefix = PoolKeyPrefix -- READ access to the debuff category groups array (C2). Never creates -- adDB.debuffGroups — merely visiting the Debuffs tab must not write @@ -1141,6 +1168,7 @@ local function DebuffGroupsRead() if adDB and adDB.debuffGroups then return GetDebuffGroups() end return EMPTY_POOL end +P.DebuffGroupsRead = DebuffGroupsRead -- Other Buffs LAYOUT GROUPS: a flat, spec-INDEPENDENT array of group records -- over the other pool (sibling of otherAuras/debuffGroups — never a pseudo-spec @@ -1175,6 +1203,7 @@ local function GetOtherLayoutGroups(create) end return groups end +P.GetOtherLayoutGroups = GetOtherLayoutGroups -- Active tab's layout groups (READ — never creates the other store): the -- flat other store on Other Buffs, the spec-keyed array on My Buffs. @@ -1184,6 +1213,7 @@ local function CurrentLayoutGroups() if S.activeBuffTab == "other" then return GetOtherLayoutGroups(false) end return GetSpecLayoutGroups() end +P.CurrentLayoutGroups = CurrentLayoutGroups -- Display name for an OTHER-pool aura key: ad-hoc "#" resolves live, -- SpellDB names resolve through GetSpellDisplay (localized), else the raw key. @@ -1201,6 +1231,7 @@ local function OtherPoolDisplayName(auraName) end return auraName end +P.OtherPoolDisplayName = OtherPoolDisplayName -- Union of every spell ID tracked by one pool of an adDB (cross-tab -- used-affordance). which = "my" walks ALL spec pools (the preset object is @@ -1243,6 +1274,7 @@ DF.ADEditor_PoolTrackedIDs = PoolTrackedIDs local function CrossPoolTrackedIDs() return PoolTrackedIDs(GetAuraDesignerDB(), IsOtherTab() and "my" or "other") end +P.CrossPoolTrackedIDs = CrossPoolTrackedIDs -- Ensure an aura config table exists, creating it with defaults if needed. -- `pool` (optional) pins the target pool — proxies capture their pool at @@ -1256,6 +1288,7 @@ local function EnsureAuraConfig(auraName, pool) end return auras[auraName] end +P.EnsureAuraConfig = EnsureAuraConfig -- Ensure a type sub-table exists within an aura config local function EnsureTypeConfig(auraName, typeKey, pool) @@ -1399,6 +1432,7 @@ local function EnsureTypeConfig(auraName, typeKey, pool) end return auraCfg[typeKey] end +P.EnsureTypeConfig = EnsureTypeConfig -- Default values per type key, used as fallback when a saved config is missing new keys local TYPE_DEFAULTS = { @@ -1702,6 +1736,7 @@ local TYPE_DEFAULTS = { showWhenMissing = false, }, } +P.TYPE_DEFAULTS = TYPE_DEFAULTS -- ============================================================ -- INSTANCE-BASED INDICATOR HELPERS @@ -1737,6 +1772,7 @@ local function CreateIndicatorInstance(auraName, typeKey) tinsert(auraCfg.indicators, instance) return instance end +P.CreateIndicatorInstance = CreateIndicatorInstance -- Find an indicator instance by its stable ID. `pool` (optional) pins the -- pool; defaults to the active tab's pool. @@ -1764,6 +1800,7 @@ local function RemoveIndicatorInstance(auraName, indicatorID) end end end +P.RemoveIndicatorInstance = RemoveIndicatorInstance -- (ChangeInstanceType removed — uncalled since the tile-strip type switcher -- left the v4 redesign; reclaimed for the 200-locals ceiling.) @@ -1812,6 +1849,7 @@ local function CopyIndicatorAppearance(srcAuraName, srcIndicatorID, dstAuraName, end end end +P.CopyIndicatorAppearance = CopyIndicatorAppearance -- Forward declaration: lightweight preview refresh (defined after RefreshPreviewEffects) -- Called from proxy __newindex so every setting change updates the preview in real-time @@ -1831,6 +1869,7 @@ local function RefreshLiveFramesThrottled() end end) end +P.RefreshLiveFramesThrottled = RefreshLiveFramesThrottled -- Global-default key mapping: which global default keys apply to placed types local GLOBAL_DEFAULT_MAP = { @@ -1899,6 +1938,7 @@ local function AddExpiryAlertControls(g, parent, proxy, include) }) g:RefreshChildStates() -- initial grey (the initial hide rides AddGroup's LayoutChildren) end +P.AddExpiryAlertControls = AddExpiryAlertControls -- Colours-S.page cross-link placed under an AD "Color by Time Remaining" TEXT control, matching -- the aura pages' duration link (jump + whole-section flash). The duration text's By-Time colour @@ -1911,6 +1951,7 @@ local function AddDurationColorsLink(g, parent) g:AddWidget(note, (note.layoutHeight or 16) + 2) return note end +P.AddDurationColorsLink = AddDurationColorsLink -- Create a proxy table that maps flat key access to an indicator instance -- Fallback chain: instance value → global defaults → TYPE_DEFAULTS @@ -1969,6 +2010,7 @@ local function CreateInstanceProxy(auraName, indicatorID) end, }) end +P.CreateInstanceProxy = CreateInstanceProxy -- Create a proxy table that maps flat key access to nested aura config local function CreateProxy(auraName, typeKey) @@ -2006,6 +2048,7 @@ local function CreateProxy(auraName, typeKey) end, }) end +P.CreateProxy = CreateProxy -- Create a proxy for the aura-level config (priority, expiring) local function CreateAuraProxy(auraName) @@ -2026,6 +2069,7 @@ local function CreateAuraProxy(auraName) end, }) end +P.CreateAuraProxy = CreateAuraProxy -- ============================================================ -- WARNING BADGE @@ -2058,6 +2102,7 @@ local function GetAuraWarningKey(specKey, auraName) end return nil end +P.GetAuraWarningKey = GetAuraWarningKey -- Attach (or refresh) a warning triangle badge on the given region. -- host: parent Frame the badge is attached to (must be a Frame). @@ -2112,6 +2157,7 @@ local function AttachWarningBadge(host, warnKey, opts) badge.tooltipText = tooltipText badge:Show() end +P.AttachWarningBadge = AttachWarningBadge -- Ad-hoc add-by-ID auras (picker "Add" with an ID the SpellDB doesn't know) -- are stored under the key "#" — the name IS the identity, so the @@ -2152,6 +2198,7 @@ local function WithConfiguredAdHocAuras(list, spec) end return out end +P.WithConfiguredAdHocAuras = WithConfiguredAdHocAuras -- Get spell icon texture for an aura -- Uses static texture IDs to avoid C_Spell.GetSpellTexture returning @@ -2185,6 +2232,7 @@ local function GetAuraIcon(specKey, auraName) end return nil end +P.GetAuraIcon = GetAuraIcon -- (CountActiveEffects removed — uncalled since the v4 flat-card redesign; -- reclaimed for the file-scope 200-locals ceiling.) @@ -2203,6 +2251,7 @@ local function GetFrameEffectTriggers(auraName, typeKey) end return { auraName } -- Default: just the owning aura end +P.GetFrameEffectTriggers = GetFrameEffectTriggers -- Add a trigger aura to a frame effect. `pool` (optional) pins the target -- pool — the floating trigger picker captures it at OPEN time so a dropdown @@ -2218,6 +2267,7 @@ local function AddFrameEffectTrigger(auraName, typeKey, triggerName, pool) end tinsert(typeCfg.triggers, triggerName) end +P.AddFrameEffectTrigger = AddFrameEffectTrigger -- Remove a trigger aura from a frame effect (minimum 1 trigger required) local function RemoveFrameEffectTrigger(auraName, typeKey, triggerName) @@ -2231,6 +2281,7 @@ local function RemoveFrameEffectTrigger(auraName, typeKey, triggerName) end end end +P.RemoveFrameEffectTrigger = RemoveFrameEffectTrigger -- ============================================================ -- SHARED SPELL PICKER STATE @@ -2252,6 +2303,7 @@ local function CloseADPicker() S.adPickerHandle:Close() end end +P.CloseADPicker = CloseADPicker -- ============================================================ -- LAYOUT GROUP HELPERS @@ -2260,6 +2312,7 @@ end -- State for expanded layout group cards local expandedGroups = {} +P.expandedGroups = expandedGroups -- expandedGroups key for a layout group id on the ACTIVE tab: raw numeric id -- on My Buffs (legacy keys, kept as-is), "othergroup:" on Other Buffs. @@ -2269,6 +2322,7 @@ local function GroupExpandKey(groupID) if IsOtherTab() then return "othergroup:" .. groupID end return groupID end +P.GroupExpandKey = GroupExpandKey -- Find which layout group (if any) an indicator belongs to — searched in the -- ACTIVE tab's group store, so an other-pool indicator only matches other-pool @@ -2287,6 +2341,7 @@ local function GetIndicatorLayoutGroup(auraName, indicatorID) end return nil end +P.GetIndicatorLayoutGroup = GetIndicatorLayoutGroup -- (GetUngroupedIndicators removed — uncalled since the group picker moved to -- the full spell-picker "group" mode; reclaimed for the 200-locals ceiling.) @@ -2336,6 +2391,7 @@ local function CreateLayoutGroup(name, kind) tinsert(groups, group) return group end +P.CreateLayoutGroup = CreateLayoutGroup -- Delete a layout group by ID (from the ACTIVE tab's store; the member- -- indicator cascade removes from the active pool via RemoveIndicatorInstance's @@ -2356,6 +2412,7 @@ local function DeleteLayoutGroup(groupID) end expandedGroups[GroupExpandKey(groupID)] = nil end +P.DeleteLayoutGroup = DeleteLayoutGroup -- Delete a debuff category group by ID (C2). No member indicators to cascade -- (category groups own no placed indicators). The caller runs the FULL @@ -2371,6 +2428,7 @@ local function DeleteDebuffGroup(groupID) end expandedGroups["dgroup:" .. groupID] = nil end +P.DeleteDebuffGroup = DeleteDebuffGroup -- Find a layout group by ID (active tab's store) local function GetLayoutGroupByID(groupID) @@ -2380,6 +2438,7 @@ local function GetLayoutGroupByID(groupID) end return nil end +P.GetLayoutGroupByID = GetLayoutGroupByID -- Add a member to a layout group local function AddGroupMember(groupID, auraName, indicatorID) @@ -2392,6 +2451,7 @@ local function AddGroupMember(groupID, auraName, indicatorID) end tinsert(group.members, { auraName = auraName, indicatorID = indicatorID }) end +P.AddGroupMember = AddGroupMember -- Remove a member from a layout group local function RemoveGroupMember(groupID, auraName, indicatorID) @@ -2404,6 +2464,7 @@ local function RemoveGroupMember(groupID, auraName, indicatorID) end end end +P.RemoveGroupMember = RemoveGroupMember -- Swap two members in a layout group (for reordering) local function SwapGroupMembers(groupID, idx1, idx2) @@ -2412,9 +2473,11 @@ local function SwapGroupMembers(groupID, idx1, idx2) if idx1 < 1 or idx1 > #group.members or idx2 < 1 or idx2 > #group.members then return end group.members[idx1], group.members[idx2] = group.members[idx2], group.members[idx1] end +P.SwapGroupMembers = SwapGroupMembers -- Anchor dot pool (populated during CreateFramePreview, used by drag system) local anchorDots = {} +P.anchorDots = anchorDots -- Anchor point positions relative to the mock frame local ANCHOR_POSITIONS = { @@ -2428,6 +2491,7 @@ local ANCHOR_POSITIONS = { BOTTOM = { x = 0.5, y = 1, ax = "BOTTOM", ay = "BOTTOM" }, BOTTOMRIGHT = { x = 1, y = 1, ax = "BOTTOMRIGHT", ay = "BOTTOMRIGHT" }, } +P.ANCHOR_POSITIONS = ANCHOR_POSITIONS -- ============================================================ -- FRAME REFERENCES (populated during build) @@ -2454,16 +2518,20 @@ local ANCHOR_POSITIONS = { S.activeTab = "effects" -- "effects" | "layout" | "global" S.activeFilter = "all" -- Filter chip state local expandedCards = {} -- { ["placed:AuraName#1"] = true, ["frame:border:AuraName"] = true } +P.expandedCards = expandedCards -- Tab system frame references -- (S.tabBar declared on the state table) local tabButtons = {} -- { effects = btn, layout = btn, global = btn } +P.tabButtons = tabButtons local mainTabButtons = {} -- { my = btn, other = btn } (B2 main pool tab strip) +P.mainTabButtons = mainTabButtons -- (S.specDropdown declared on the state table) -- (S.specDropdownUpdate declared on the state table) -- (S.tabContentFrame declared on the state table) -- (S.tabScrollFrame declared on the state table) local effectCardPool = {} -- Reusable card frames +P.effectCardPool = effectCardPool -- ============================================================ -- EFFECTS LIST DATA COLLECTION @@ -2529,6 +2597,7 @@ local BADGE_COLORS = { healthtext = { r = 0.72, g = 0.72, b = 0.94 }, -- Light blue sound = { r = 0.94, g = 0.76, b = 0.24 }, -- Gold/yellow } +P.BADGE_COLORS = BADGE_COLORS -- Collect all configured effects into a flat, sorted list -- Returns: { { source="placed"|"frame", auraName, typeKey, ... }, ... } @@ -2609,6 +2678,7 @@ local function CollectAllEffects() return effects end +P.CollectAllEffects = CollectAllEffects -- Check if a specific aura + type combo already has a placed indicator local function IsAuraTypePlaced(auraName, typeKey) @@ -2619,6 +2689,7 @@ local function IsAuraTypePlaced(auraName, typeKey) end return false end +P.IsAuraTypePlaced = IsAuraTypePlaced -- ============================================================ -- DRAG AND DROP SYSTEM @@ -2637,6 +2708,7 @@ local dragState = { moveIndicatorID = nil, -- Set when re-dragging an existing placed indicator indicatorType = nil, -- "icon" | "square" | "bar" — type to create on drop } +P.dragState = dragState S.dragGhost = nil S.dragUpdateFrame = nil @@ -2834,6 +2906,7 @@ end -- ============================================================ local placedIndicators = {} +P.placedIndicators = placedIndicators -- Set by ClearPlacedIndicators, consumed by the S.page's RefreshStates: tab -- switching re-enters the S.page through GUI RefreshCached -> RefreshStates ONLY @@ -2884,6 +2957,7 @@ local function ClearPlacedIndicators() end S.placedCleared = true end +P.ClearPlacedIndicators = ClearPlacedIndicators -- ============================================================ -- NATIVE PREVIEW SLOTS (12.1) @@ -3458,6 +3532,7 @@ local function RefreshPlacedIndicators() end end end +P.RefreshPlacedIndicators = RefreshPlacedIndicators -- ============================================================ -- PREVIEW EFFECTS @@ -3610,6 +3685,7 @@ local function RefreshPreviewEffects() end -- for _, entry in sortedAuras end +P.RefreshPreviewEffects = RefreshPreviewEffects -- ============================================================ -- LIGHTWEIGHT PREVIEW REFRESH @@ -4752,6 +4828,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff parent:SetHeight(totalHeight) return widgets, totalHeight end +P.BuildTypeContent = BuildTypeContent -- ============================================================ -- GLOBAL VIEW (used by Global tab) @@ -5142,6 +5219,7 @@ local function CreateEnableBanner(parent) banner.checkbox = cb return banner end +P.CreateEnableBanner = CreateEnableBanner -- ============================================================ -- SPEC DROPDOWN (B2: relocated from the enable banner onto the @@ -5257,6 +5335,7 @@ local function CreateSpecDropdown(parent) return specDrop, UpdateSpecText end +P.CreateSpecDropdown = CreateSpecDropdown -- ============================================================ -- FRAME PREVIEW @@ -5544,6 +5623,7 @@ local function CreateFramePreview(parent, yOffset, rightPanelRef) return container end +P.CreateFramePreview = CreateFramePreview -- ============================================================ -- TAB SYSTEM, SPELL PICKER & EFFECT CARDS (v4 redesign) @@ -5767,6 +5847,7 @@ local function UpdateLayoutTabState() effectsBtn:SetDisabled(IsDebuffTab()) end end +P.UpdateLayoutTabState = UpdateLayoutTabState -- Grey the spec dropdown + swap its opener text on the Other and Debuffs -- tabs (both pools are shared across specs); restore the live spec text on @@ -5786,6 +5867,7 @@ local function UpdateSpecDropdownState() if S.specDropdownUpdate then S.specDropdownUpdate() end end end +P.UpdateSpecDropdownState = UpdateSpecDropdownState local function SetMainTab(tabKey) if S.activeBuffTab == tabKey then return end @@ -5814,6 +5896,7 @@ local function SetMainTab(tabKey) -- (preview, drag targets) — all pool-routed through CurrentAuraPool. DF:AuraDesigner_RefreshPage() end +P.SetMainTab = SetMainTab -- ── ADD FROM PICKER (shared path) ── -- What accepting a spell in the picker DOES: create the placed indicator @@ -6085,6 +6168,7 @@ local function OpenGroupSpellPicker(groupID) end, }) end +P.OpenGroupSpellPicker = OpenGroupSpellPicker -- ── CREATE EFFECT CARD ── -- Creates a collapsible card for one effect in the effects list. @@ -7149,6 +7233,7 @@ local function AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) return by end +P.AddGroupAppearanceSection = AddGroupAppearanceSection S.BuildLayoutGroupsTab = function() if not S.tabContentFrame then return end From 4bb27b00f738fa0b2ba261d0b6e55f4ac105b89f Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 17:59:05 +0100 Subject: [PATCH 07/34] Split the Aura Designer editor (9,093 lines) into five files Pure move, proven not asserted. split_verify.py concatenates the five parts and compares byte-for-byte against the parent commit: identical apart from 159 alias lines, each listed in docs/reorg-tools/splits.manifest. Options.lua 1,738 config resolvers, spec/pool helpers, group CRUD, state and _priv tables Groups.lua 2,099 layout + debuff group management, indicator instances, proxies, warning badges Indicators.lua 1,041 anchors, drag state, placed-indicator handling Cards.lua 2,463 effect cards and the per-type content builders Editor.lua 1,916 tab bar, panels, preview, BuildAuraDesignerPage Parts 2-5 open with alias headers (19-58 lines) re-declaring what they use: GUI, Adapter, the colour constants, the state table and the shared helpers. Same objects, so no call site changed. Every part is well under Lua's 200-locals-per-chunk ceiling -- the largest is 86, against 196 before this work started. That ceiling was not hypothetical here: the file carried a comment recording a constant deleted purely to reclaim a slot. split_verify now takes a per-entry `base:` in the manifest. A single global base cannot work once a split is committed, because from then on HEAD's original file IS part 1 and the check compares it against itself. --- AuraDesigner/UI/Cards.lua | 2463 +++++++ AuraDesigner/UI/Editor.lua | 1915 ++++++ AuraDesigner/UI/Groups.lua | 2099 ++++++ AuraDesigner/UI/Indicators.lua | 1041 +++ AuraDesigner/UI/Options.lua | 10815 +++++-------------------------- DandersFrames.toc | 6 + 6 files changed, 9252 insertions(+), 9087 deletions(-) create mode 100644 AuraDesigner/UI/Cards.lua create mode 100644 AuraDesigner/UI/Editor.lua create mode 100644 AuraDesigner/UI/Groups.lua create mode 100644 AuraDesigner/UI/Indicators.lua diff --git a/AuraDesigner/UI/Cards.lua b/AuraDesigner/UI/Cards.lua new file mode 100644 index 00000000..98c763b6 --- /dev/null +++ b/AuraDesigner/UI/Cards.lua @@ -0,0 +1,2463 @@ +-- Part 4 of the Aura Designer editor, split from Options.lua. +-- Aliases of objects the first part created; they add no state. +local addonName, DF = ... +local L = DF.L +local GUI = DF.GUI +local Adapter = DF.AuraDesigner.Adapter +local S = DF.AuraDesigner._uiState +local P = DF.AuraDesigner._priv +local C_ELEMENT = GUI.Colors.element +local C_BORDER = GUI.Colors.border +local C_HOVER = GUI.Colors.hover +local C_TEXT = GUI.Colors.text +local C_TEXT_DIM = GUI.Colors.textDim +local OPTS = P.OPTS +local GetAuraDesignerDB = P.GetAuraDesignerDB +local GetThemeColor = P.GetThemeColor +local ApplyBackdrop = P.ApplyBackdrop +local CreateCardShell = P.CreateCardShell +local ShowBuffCoexistPopup = P.ShowBuffCoexistPopup +local ResolveSpec = P.ResolveSpec +local IsOtherTab = P.IsOtherTab +local IsDebuffTab = P.IsDebuffTab +local CurrentAuraPool = P.CurrentAuraPool +local PoolKeyPrefix = P.PoolKeyPrefix +local OtherPoolDisplayName = P.OtherPoolDisplayName +local CrossPoolTrackedIDs = P.CrossPoolTrackedIDs +local EnsureTypeConfig = P.EnsureTypeConfig +local TYPE_DEFAULTS = P.TYPE_DEFAULTS +local CreateIndicatorInstance = P.CreateIndicatorInstance +local RemoveIndicatorInstance = P.RemoveIndicatorInstance +local RefreshLiveFramesThrottled = P.RefreshLiveFramesThrottled +local AddDurationColorsLink = P.AddDurationColorsLink +local CreateInstanceProxy = P.CreateInstanceProxy +local CreateProxy = P.CreateProxy +local CreateAuraProxy = P.CreateAuraProxy +local GetAuraWarningKey = P.GetAuraWarningKey +local AttachWarningBadge = P.AttachWarningBadge +local WithConfiguredAdHocAuras = P.WithConfiguredAdHocAuras +local GetAuraIcon = P.GetAuraIcon +local GetFrameEffectTriggers = P.GetFrameEffectTriggers +local AddFrameEffectTrigger = P.AddFrameEffectTrigger +local RemoveFrameEffectTrigger = P.RemoveFrameEffectTrigger +local CloseADPicker = P.CloseADPicker +local GetIndicatorLayoutGroup = P.GetIndicatorLayoutGroup +local GetLayoutGroupByID = P.GetLayoutGroupByID +local AddGroupMember = P.AddGroupMember +local anchorDots = P.anchorDots +local ANCHOR_POSITIONS = P.ANCHOR_POSITIONS +local expandedCards = P.expandedCards +local tabButtons = P.tabButtons +local mainTabButtons = P.mainTabButtons +local BADGE_COLORS = P.BADGE_COLORS +local CollectAllEffects = P.CollectAllEffects +local IsAuraTypePlaced = P.IsAuraTypePlaced +local dragState = P.dragState +local RefreshPlacedIndicators = P.RefreshPlacedIndicators +local RefreshPreviewEffects = P.RefreshPreviewEffects +local BuildTypeContent = P.BuildTypeContent +P.BuildTypeContent = BuildTypeContent + +-- ============================================================ +-- GLOBAL VIEW (used by Global tab) +-- ============================================================ + +-- Hardcoded fallbacks for global defaults (used when profile is missing new keys) +local GLOBAL_DEFAULTS_FALLBACK = { + iconSize = 24, iconScale = 1.0, + showDuration = true, showStacks = true, + durationFont = "DF Roboto SemiBold", durationScale = 1.2, + durationOutline = "SHADOW;OUTLINE", durationAnchor = "CENTER", + durationX = 0, durationY = 0, durationColorByTime = true, + durationColor = {r = 1, g = 1, b = 1, a = 1}, + durationHideAboveEnabled = false, durationHideAboveThreshold = 10, + stackFont = "DF Roboto SemiBold", stackScale = 1.0, + stackOutline = "SHADOW;OUTLINE", stackAnchor = "BOTTOMRIGHT", + stackX = 2, stackY = -2, + stackColor = {r = 1, g = 1, b = 1, a = 1}, + iconBorderEnabled = true, iconBorderThickness = 1, + hideSwipe = false, hideIcon = false, +} + +local function BuildGlobalView(parent) + local adDB = GetAuraDesignerDB() + local rawDefaults = adDB.defaults + -- Proxy so every write triggers a full preview rebuild + -- (global defaults affect ALL indicators, need full teardown/rebuild) + -- Falls back to GLOBAL_DEFAULTS_FALLBACK for keys missing from existing profiles. + -- __dfDefaults exposes the fallback table to GUI:CreateColorPicker's Default button. + local defaults = setmetatable({ _skipOverrideIndicators = true, __dfDefaults = GLOBAL_DEFAULTS_FALLBACK }, { + __index = function(_, k) + local v = rawDefaults[k] + if v ~= nil then return v end + return GLOBAL_DEFAULTS_FALLBACK[k] + end, + __newindex = function(_, k, v) + rawDefaults[k] = v + RefreshPlacedIndicators() + RefreshPreviewEffects() + RefreshLiveFramesThrottled() + end, + }) + + local parentW = parent:GetWidth() + if parentW < 50 then parentW = 280 end + local contentWidth = parentW - 16 -- 8px padding each side + local totalHeight = 8 + local widgets = {} + local function RPL() if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end end + + local function AddWidget(widget, height) + widget:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -totalHeight) + if widget.SetWidth then widget:SetWidth(contentWidth - 10) end + tinsert(widgets, widget) + totalHeight = totalHeight + (height or 30) + end + + local function AddGroup(header, buildFn) + local group = GUI:CreateSettingsGroup(parent, contentWidth - 10) + group.padding = 10 -- match the main Options groups' inner padding (airier scale) + group:AddWidget(GUI:CreateHeader(parent, header), GUI.RowHeight.sectionHeader) + buildFn(group) + local h = group:LayoutChildren() + AddWidget(group, h) + end + + -- ── GENERAL ── + AddGroup(L["General"], function(g) + g:AddWidget(GUI:CreateSlider(parent, L["Default Icon Size"], 8, 64, 1, defaults, "iconSize"), 50) + g:AddWidget(GUI:CreateSlider(parent, L["Default Scale"], 0.5, 3.0, 0.05, defaults, "iconScale"), 50) + -- Both LIVE on the container path: Factory.ResolveDefaults bundles them into the + -- per-pass `defs` table, resolveLevel/resolveStrata walk instance -> global default + -- (the same chain GLOBAL_DEFAULT_MAP gives the editor proxy, so the two agree), and + -- AuraContainer's _applyZOrder sets level and strata from the resolved config. + -- Both ship as no-ops -- level 0, strata INHERIT -- so an untouched profile renders + -- exactly where it did before they were wired. + g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Default Frame Level"], 0, 100, 1, defaults, "indicatorFrameLevel")), 50) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], defaults, "showDuration"), 24) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], defaults, "showStacks"), 24) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], defaults, "hideSwipe"), 24) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Icon (Text Only)"], defaults, "hideIcon"), 24) + end) + + -- ── SOUND ALERTS ── + -- Set-once settings, relocated here from the enable banner (§11.6 redesign). + -- Storage keys unchanged: soundEnabled (nil/true = on, false = muted) and + -- soundChannel (Master default: alerts should stay audible when the player + -- mutes Sound Effects/Music to cut combat noise). + AddGroup(L["Sound Alerts"], function(g) + local SOUND_CHANNELS = { + Master = L["Master"], + SFX = L["Sound Effects"], + Music = L["Music"], + Ambience = L["Ambience"], + Dialog = L["Dialog"], + _order = { "Master", "SFX", "Music", "Ambience", "Dialog" }, + } + g:AddWidget(GUI:CreateCheckbox(parent, L["Enabled"], nil, nil, nil, + function() return GetAuraDesignerDB().soundEnabled ~= false end, -- customGet + function(v) -- customSet + local adDB = GetAuraDesignerDB() + adDB.soundEnabled = v and true or false + if not adDB.soundEnabled and DF.AuraDesigner.SoundEngine then + DF.AuraDesigner.SoundEngine:StopAll() + end + end), 24) + g:AddWidget(GUI:CreateDropdown(parent, L["Channel"], SOUND_CHANNELS, + nil, nil, nil, + function() return (GetAuraDesignerDB().soundChannel) or "Master" end, -- customGet + function(key) GetAuraDesignerDB().soundChannel = key end), 50) -- customSet + end) + + -- ── DURATION TEXT ── + AddGroup(L["Duration Text"], function(g) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], defaults, "durationFont"), 50) + g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 2.0, 0.1, defaults, "durationScale"), 50) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], defaults, "durationOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], defaults, "durationOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, defaults, "durationAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, defaults, "durationX"), 50) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, defaults, "durationY"), 50) + g:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], defaults, "durationColorByTime"), 24) + AddDurationColorsLink(g, parent) + g:AddWidget(GUI:CreateColorPicker(parent, L["Duration Text Color"], defaults, "durationColor", true, RPL, RPL, true), 32) + local hideAboveSlider + local function UpdateHideAboveState() + if not hideAboveSlider then return end + if defaults.durationHideAboveEnabled then + hideAboveSlider:SetAlpha(1) + hideAboveSlider:EnableMouse(true) + else + hideAboveSlider:SetAlpha(0.4) + hideAboveSlider:EnableMouse(false) + end + end + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], defaults, "durationHideAboveEnabled", UpdateHideAboveState), 24) + hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, defaults, "durationHideAboveThreshold") + g:AddWidget(hideAboveSlider, 50) + UpdateHideAboveState() + end) + + -- ── STACK TEXT ── + AddGroup(L["Stack Text"], function(g) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], defaults, "stackFont"), 50) + g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 2.0, 0.1, defaults, "stackScale"), 50) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], defaults, "stackOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], defaults, "stackOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, defaults, "stackAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, defaults, "stackX"), 50) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, defaults, "stackY"), 50) + g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], defaults, "stackColor", true, RPL, RPL, true), 32) + end) + + -- ── IMPORT FROM BUFFS TAB ── + AddGroup(L["Import from Buffs Tab"], function(g) + local descFrame = CreateFrame("Frame", nil, parent) + descFrame:SetHeight(36) + local descText = descFrame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + descText:SetPoint("TOPLEFT", 0, 0) + descText:SetPoint("RIGHT", descFrame, "RIGHT", 0, 0) + descText:SetJustifyH("LEFT") + descText:SetWordWrap(true) + descText:SetText(L["Import your existing Buffs tab settings as defaults for all auras. Compatible settings will be applied automatically."]) + descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + g:AddWidget(descFrame, 36) + + -- Compatibility list + local compatItems = { + {true, L["Icon size, scale & border"]}, + {true, L["Duration & stack display"]}, + {true, L["Font Settings"]}, + {false, L["Position & anchors"]}, + {false, L["Per-aura overrides"]}, + } + for _, item in ipairs(compatItems) do + local isCompat = item[1] + local row = CreateFrame("Frame", nil, parent) + row:SetHeight(16) + local lbl = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 8, 0) + if isCompat then + lbl:SetText("|TInterface\\AddOns\\DandersFrames\\Media\\Icons\\check:12:12|t " .. item[2]) + else + lbl:SetText("|TInterface\\AddOns\\DandersFrames\\Media\\Icons\\close:12:12|t " .. item[2]) + end + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + g:AddWidget(row, 16) + end + + -- Import button + local importBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + DF.GUI:StyleButton(importBtn, { height = 26, text = L["Import Buffs Tab Defaults"] }) + importBtn:SetScript("OnClick", function() + local mode = (GUI and GUI.SelectedMode) or "party" + local buffsDB = DF:GetDB(mode) + if buffsDB and defaults then + if buffsDB.buffSize then defaults.iconSize = buffsDB.buffSize end + if buffsDB.buffScale then defaults.iconScale = buffsDB.buffScale end + if buffsDB.buffShowDuration ~= nil then defaults.showDuration = buffsDB.buffShowDuration end + if buffsDB.buffShowStacks ~= nil then defaults.showStacks = buffsDB.buffShowStacks end + if buffsDB.buffBorder ~= nil then defaults.iconBorderEnabled = buffsDB.buffBorder end + if buffsDB.buffDurationFont then defaults.durationFont = buffsDB.buffDurationFont end + if buffsDB.buffDurationScale then defaults.durationScale = buffsDB.buffDurationScale end + if buffsDB.buffDurationOutline then defaults.durationOutline = buffsDB.buffDurationOutline end + if buffsDB.buffStackFont then defaults.stackFont = buffsDB.buffStackFont end + if buffsDB.buffStackScale then defaults.stackScale = buffsDB.buffStackScale end + if buffsDB.buffStackOutline then defaults.stackOutline = buffsDB.buffStackOutline end + DF:Debug("AD", "Imported Buffs tab defaults") + importBtn.Text:SetText(L["Imported!"]) + C_Timer.After(1.5, function() importBtn.Text:SetText(L["Import Buffs Tab Defaults"]) end) + DF:AuraDesigner_RefreshPage() + end + end) + g:AddWidget(importBtn, 32) + end) + + -- ── STANDARD BUFFS ── + -- Replaces the old coexistence banner's "Disable Buffs" shortcut: standard + -- buff visibility is Aura Filters' job now, so this just links there. + AddGroup(L["Standard Buffs"], function(g) + local descFrame = CreateFrame("Frame", nil, parent) + descFrame:SetHeight(24) + local descText = descFrame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + descText:SetPoint("TOPLEFT", 0, 0) + descText:SetPoint("RIGHT", descFrame, "RIGHT", 0, 0) + descText:SetJustifyH("LEFT") + descText:SetWordWrap(true) + descText:SetText(L["Standard buff visibility is managed on the Aura Filters S.page."]) + descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + g:AddWidget(descFrame, 24) + + local filtersBtn = GUI:CreateButton(parent, L["Aura Filters"], 140, 22, function() + if GUI.SelectTab and GUI.Pages and GUI.Pages["auras_filterdesigner"] then + GUI.SelectTab("auras_filterdesigner") + end + end) + if not (GUI.Pages and GUI.Pages["auras_filterdesigner"]) then + filtersBtn:Disable() + filtersBtn.Text:SetTextColor(0.4, 0.4, 0.4) + end + g:AddWidget(filtersBtn, 28) + end) + + -- ── ACTIONS ── + AddGroup(L["Actions"], function(g) + -- Copy Settings to Other Mode button + local currentMode = (GUI and GUI.SelectedMode) or "party" + local targetMode = (currentMode == "party") and "raid" or "party" + local targetLabel = (targetMode == "raid") and L["Raid"] or L["Party"] + + local copyBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + DF.GUI:StyleButton(copyBtn, { height = 26, text = format(L["Copy Settings to %s"], targetLabel) }) + copyBtn:SetScript("OnClick", function() + local srcMode = (GUI and GUI.SelectedMode) or "party" + local dstMode = (srcMode == "party") and "raid" or "party" + -- Copy at the preset level: the source mode's preset content is + -- copied INTO the dest mode's preset, in place, so the dest preset + -- object identity (and every consumer bound to it) is preserved. + -- BASE resolvers: this S.page edits the user's BASE presets — with a + -- runtime auto-layout active, the ACTIVE resolver would copy + -- from/into the layout's preset instead. + local source = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(srcMode)) + or (DF.GetModeAuraDesigner and DF:GetModeAuraDesigner(srcMode)) + or (DF:GetDB(srcMode) and DF:GetDB(srcMode).auraDesigner) + local dest = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(dstMode)) + or (DF.GetModeAuraDesigner and DF:GetModeAuraDesigner(dstMode)) + or (DF:GetDB(dstMode) and DF:GetDB(dstMode).auraDesigner) + if source and dest and source ~= dest then + local function DeepCopy(src) + if type(src) ~= "table" then return src end + local copy = {} + for k, v in pairs(src) do copy[k] = DeepCopy(v) end + return copy + end + -- Clear stale dest keys the source no longer has, then overwrite. + for k in pairs(dest) do dest[k] = nil end + for k, v in pairs(source) do dest[k] = DeepCopy(v) end + end + DF:Debug("AD", "Copied %s settings to %s", tostring(srcMode), tostring(dstMode)) + end) + g:AddWidget(copyBtn, 32) + + -- Reset All button + local resetBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + resetBtn:SetHeight(26) + -- Persistent-red destructive button via the shared styler, now gated by a + -- confirmation (was a one-click wipe). + DF.GUI:StyleButton(resetBtn, { height = 26, primary = true, accent = { r = 0.8, g = 0.25, b = 0.25 }, text = L["Reset All Aura Configs"] }) + resetBtn:SetScript("OnClick", function() + DF:ShowPopupAlert({ + title = L["Reset All Aura Configs"], + message = L["Reset ALL aura configurations to defaults?\n\nThis cannot be undone."], + buttons = { + { + label = L["Reset"], + onClick = function() + wipe(GetAuraDesignerDB().auras) + -- "Reset ALL" covers the Other Buffs pool too (B2) + if GetAuraDesignerDB().otherAuras then + wipe(GetAuraDesignerDB().otherAuras) + end + DF:AuraDesigner_RefreshPage() + RefreshLiveFramesThrottled() + DF:Debug("AD", "Reset all aura configurations") + end, + }, + { label = L["Cancel"] }, + }, + }) + end) + g:AddWidget(resetBtn, 32) + end) + + parent:SetHeight(totalHeight + 10) +end + +-- BuildPerAuraView + RefreshRightPanel removed in v4 redesign +-- Per-aura configuration is now done via flat effect cards in the Effects tab +-- (their dummy stubs were unreferenced — reclaimed for the 200-locals ceiling) + +-- ============================================================ +-- ENABLE BANNER +-- ============================================================ + +local function CreateEnableBanner(parent) + local banner = CreateFrame("Frame", nil, parent, "BackdropTemplate") + -- Two-row layout: row 1 (36px) has Enable toggle (left) + Sync/Copy buttons + -- (right); row 2 (32px) is the preset bar (anchored into the banner by the + -- S.page build; the spec dropdown moved onto the B2 main tab strip). Sound + -- Alerts live on the Global tab (set-once settings). + banner:SetHeight(68) + banner:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0) + banner:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0) + GUI:CreatePanelBackdrop(banner, {borderColor = {r = 0.30, g = 0.30, b = 0.30, a = 0.5}}) + + -- Subtle divider between the two rows + local rowDivider = banner:CreateTexture(nil, "BACKGROUND") + rowDivider:SetHeight(1) + rowDivider:SetPoint("TOPLEFT", banner, "TOPLEFT", 0, -36) + rowDivider:SetPoint("TOPRIGHT", banner, "TOPRIGHT", 0, -36) + rowDivider:SetColorTexture(0.25, 0.25, 0.25, 1) + + -- Themed checkbox (matches GUI:CreateCheckbox style) + -- Row 1 centre = 18px from top. Banner centre = 34px from top. + -- Offset from banner centre to row 1 centre = +16. + local cb = CreateFrame("CheckButton", nil, banner, "BackdropTemplate") + cb:SetPoint("LEFT", banner, "LEFT", 10, 16) + DF.GUI:StyleCheckButton(cb) + + local adDB = GetAuraDesignerDB() + cb:SetChecked(adDB and adDB.enabled) + + cb:SetScript("OnClick", function(self) + local checked = self:GetChecked() + if checked then + -- Show popup asking about buff coexistence + ShowBuffCoexistPopup(function(keepBuffs) + GetAuraDesignerDB().enabled = true + S.db.showBuffs = keepBuffs + DF:AuraDesigner_RefreshPage() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end, function() + -- Cancelled — revert checkbox + self:SetChecked(false) + end) + else + GetAuraDesignerDB().enabled = false + DF:AuraDesigner_RefreshPage() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + -- Sync AD indicators to the now-disabled state — clears the leftover + -- indicators instead of leaving them frozen on screen until /reload. + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end + end) + + local cbLabel = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") + cbLabel:SetPoint("LEFT", cb, "RIGHT", 8, 0) + cbLabel:SetText(L["Enable Aura Designer"]) + cbLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + banner.checkbox = cb + return banner +end +P.CreateEnableBanner = CreateEnableBanner + +-- ============================================================ +-- SPEC DROPDOWN (B2: relocated from the enable banner onto the +-- main tab strip's right end — it only applies to My Buffs; the +-- Other Buffs tab greys it with a "shared across specs" caption) +-- ============================================================ + +local function CreateSpecDropdown(parent) + -- Spec selector. Ported to the shared GUI:CreateDropdown (inline mode, so the + -- container is just the opener button — the "Spec:" label beside it is + -- hand-placed by the S.page build). optionsFunc rebuilds the list each open so + -- the "Auto (Spec Name)" text always reflects the live detected spec. + -- The shared dropdown supports per-option colour (the `color` field), so the + -- class-coloured menu entries are preserved. (The OPENER text stays standard + -- colour — the shared opener isn't per-value colourable.) + local SPEC_ORDER = { + "auto", + -- Grouped by class (class order), specs in spec-index order + "ArmsWarrior", "FuryWarrior", "ProtectionWarrior", + "HolyPaladin", "ProtectionPaladin", "RetributionPaladin", + "BeastMasteryHunter", "MarksmanshipHunter", "SurvivalHunter", + "AssassinationRogue", "OutlawRogue", "SubtletyRogue", + "DisciplinePriest", "HolyPriest", "ShadowPriest", + "BloodDeathKnight", "FrostDeathKnight", "UnholyDeathKnight", + "ElementalShaman", "EnhancementShaman", "RestorationShaman", + "ArcaneMage", "FireMage", "FrostMage", + "AfflictionWarlock", "DemonologyWarlock", "DestructionWarlock", + "BrewmasterMonk", "MistweaverMonk", "WindwalkerMonk", + "BalanceDruid", "FeralDruid", "GuardianDruid", "RestorationDruid", + "HavocDemonHunter", "VengeanceDemonHunter", "DevourerDemonHunter", + "DevastationEvoker", "PreservationEvoker", "AugmentationEvoker", + } + local function SpecOptionText(specKey) + if specKey == "auto" then + local autoSpec = Adapter:GetPlayerSpec() + if autoSpec then + return format(L["Auto (%s)"], Adapter:GetSpecDisplayName(autoSpec)) + end + return L["Auto (detect spec)"] + end + return Adapter:GetSpecDisplayName(specKey) + end + local function SpecOptionColor(specKey) + local resolved = specKey + if specKey == "auto" then resolved = Adapter:GetPlayerSpec() end + local info = resolved and DF.AuraDesigner.SpecInfo and DF.AuraDesigner.SpecInfo[resolved] + local cc = info and info.class and RAID_CLASS_COLORS[info.class] + if cc then return { r = cc.r, g = cc.g, b = cc.b } end + return nil + end + -- All-spec menu: "Auto ()" pinned first, then every spec grouped + -- under a class-coloured header row, with the shared dropdown's inline + -- search (opts.searchable) so 41 entries stay navigable. + local function BuildSpecOptions() + local order = {} + local options = { _order = order } + tinsert(order, "auto") + options.auto = { + value = "auto", + text = SpecOptionText("auto"), + color = SpecOptionColor("auto"), + } + local lastClass + for _, specKey in ipairs(SPEC_ORDER) do + if specKey ~= "auto" then + local info = DF.AuraDesigner.SpecInfo and DF.AuraDesigner.SpecInfo[specKey] + local classToken = info and info.class + if classToken and classToken ~= lastClass then + lastClass = classToken + local hdrKey = "__hdr_" .. classToken + local cc = RAID_CLASS_COLORS[classToken] + tinsert(order, hdrKey) + options[hdrKey] = { + header = true, + text = (LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[classToken]) or classToken, + color = cc and { r = cc.r, g = cc.g, b = cc.b } or nil, + } + end + tinsert(order, specKey) + options[specKey] = { + value = specKey, + text = SpecOptionText(specKey), + color = SpecOptionColor(specKey), + } + end + end + return options + end + + local specDrop = GUI:CreateDropdown( + parent, "", BuildSpecOptions(), + nil, nil, nil, + function() return GetAuraDesignerDB().spec or "auto" end, -- customGet + function(key) -- customSet + GetAuraDesignerDB().spec = key + -- Clear expanded cards (auras change with spec), and close the + -- shared spell picker — its records/handlers captured the OLD + -- spec's state at open time (same staleness as a tab switch). + wipe(expandedCards) + CloseADPicker() + DF:AuraDesigner_RefreshPage() + end, + -- menuAlign RIGHT: the opener sits near the strip's right side and the + -- menu is wider than it, so surplus width grows leftward (menu TOPRIGHT + -- pinned to the opener's BOTTOMRIGHT) instead of spilling off the edge. + { inline = true, optionsFunc = BuildSpecOptions, searchable = true, menuAlign = "RIGHT" } + ) + + local function UpdateSpecText() + if specDrop.RebuildOptions then specDrop:RebuildOptions(BuildSpecOptions()) end + if specDrop.UpdateText then specDrop:UpdateText() end + end + + return specDrop, UpdateSpecText +end +P.CreateSpecDropdown = CreateSpecDropdown + +-- ============================================================ +-- FRAME PREVIEW +-- Mock unit frame with health bar, power bar, name, health %, +-- and 9 anchor point dots for indicator placement +-- ============================================================ + +local function CreateFramePreview(parent, yOffset, rightPanelRef) + -- Read current frame settings for the preview + local mode = (GUI and GUI.SelectedMode) or "party" + local frameDB = DF:GetDB(mode) or DF.PartyDefaults + local FRAME_W = frameDB.frameWidth or 125 + local FRAME_H = frameDB.frameHeight or 64 + local POWER_H = frameDB.powerBarHeight or 4 + local showPower = frameDB.showPowerBar + + -- Preview scale from AD settings + local adDB = GetAuraDesignerDB() + local previewScale = adDB.previewScale or 1.0 + + -- Outer container with label + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + local rightInset = rightPanelRef and (rightPanelRef:GetWidth() + 6) or 0 + container:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, yOffset) + container:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -rightInset, yOffset) + container:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0) + container:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -rightInset, 0) + -- Dark bg + DIM border (matches Text Designer; no solid white outline). + ApplyBackdrop(container, {r = 0.10, g = 0.10, b = 0.10, a = 1}, {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}) + -- Apply the subtle spec class-color hint immediately. CreateFramePreview runs + -- on every S.page build — including a party/raid rebuild (S.page:Refresh always + -- rebuilds) — so without this the new preview falls back to the dim default + -- border until the next AuraDesigner_RefreshPage (spec change / tab revisit). + local cbSpec = ResolveSpec() + local cbInfo = cbSpec and DF.AuraDesigner.SpecInfo and DF.AuraDesigner.SpecInfo[cbSpec] + local cbColor = cbInfo and cbInfo.class and RAID_CLASS_COLORS[cbInfo.class] + if cbColor then + container:SetBackdropBorderColor(cbColor.r, cbColor.g, cbColor.b, 0.5) + end + + -- "Frame Preview" label + local previewLabel = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + previewLabel:SetPoint("TOPLEFT", 8, -4) + previewLabel:SetText(L["FRAME PREVIEW"]) + previewLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Mock unit frame (centered in container) + local mockFrame = CreateFrame("Frame", nil, container, "BackdropTemplate") + mockFrame:SetSize(FRAME_W, FRAME_H) + mockFrame:SetPoint("CENTER", container, "CENTER", 0, -4) + mockFrame:SetScale(previewScale) + ApplyBackdrop(mockFrame, {r = 0.07, g = 0.07, b = 0.07, a = 1}, {r = 0.27, g = 0.27, b = 0.27, a = 1}) + container.mockFrame = mockFrame + + -- Resolve health texture + local healthTexPath = frameDB.healthTexture or "Interface\\Buttons\\WHITE8x8" + + -- Health bar background + local healthBg = mockFrame:CreateTexture(nil, "BACKGROUND") + healthBg:SetPoint("TOPLEFT", 1, -1) + if showPower then + healthBg:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, POWER_H + 1) + else + healthBg:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, 1) + end + healthBg:SetColorTexture(0, 0, 0, 0.4) + -- Exposed so the preview can tint the background when an AD Background Color + -- effect is configured. + container.healthBg = healthBg + + -- Health bar fill (72% health) + local healthFill = mockFrame:CreateTexture(nil, "ARTWORK") + healthFill:SetPoint("TOPLEFT", 1, -1) + if showPower then + healthFill:SetPoint("BOTTOMLEFT", mockFrame, "BOTTOMLEFT", 1, POWER_H + 1) + else + healthFill:SetPoint("BOTTOMLEFT", mockFrame, "BOTTOMLEFT", 1, 1) + end + healthFill:SetWidth(FRAME_W * 0.72) + healthFill:SetTexture(healthTexPath) + healthFill:SetVertexColor(0.18, 0.80, 0.44, 0.85) + container.healthFill = healthFill + + -- Missing health region + local missingHealth = mockFrame:CreateTexture(nil, "ARTWORK") + missingHealth:SetPoint("TOPRIGHT", mockFrame, "TOPRIGHT", -1, -1) + if showPower then + missingHealth:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, POWER_H + 1) + else + missingHealth:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, 1) + end + missingHealth:SetWidth(FRAME_W * 0.28) + missingHealth:SetColorTexture(0, 0, 0, 0.4) + -- Exposed so the preview can tint the missing-health region when the + -- health-bar indicator is in Tint mode with "Tint Entire Bar" enabled. + container.missingHealth = missingHealth + + -- Power bar (only if enabled in settings) + if showPower then + local powerBg = mockFrame:CreateTexture(nil, "ARTWORK") + powerBg:SetPoint("BOTTOMLEFT", 1, 1) + powerBg:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, 0) + powerBg:SetHeight(POWER_H) + powerBg:SetColorTexture(0.07, 0.07, 0.07, 1) + + local powerFill = mockFrame:CreateTexture(nil, "ARTWORK", nil, 1) + powerFill:SetPoint("BOTTOMLEFT", 1, 1) + powerFill:SetHeight(POWER_H) + powerFill:SetWidth(FRAME_W * 0.85) + powerFill:SetColorTexture(0.27, 0.53, 1, 0.9) + + -- Power bar top border + local powerBorder = mockFrame:CreateTexture(nil, "ARTWORK", nil, 2) + powerBorder:SetPoint("BOTTOMLEFT", mockFrame, "BOTTOMLEFT", 1, POWER_H) + powerBorder:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, POWER_H) + powerBorder:SetHeight(1) + powerBorder:SetColorTexture(0.2, 0.2, 0.2, 1) + end + + -- Resolve fonts from settings + local nameFontPath = DF:GetFontPath(frameDB.nameFont) or "Fonts\\FRIZQT__.TTF" + local nameFontSize = frameDB.nameFontSize or 11 + local healthFontPath = DF:GetFontPath(frameDB.healthFont) or "Fonts\\FRIZQT__.TTF" + local healthFontSize = frameDB.healthFontSize or 10 + + -- Name text (uses user's font + anchor settings) + local nameAnchor = frameDB.nameTextAnchor or "TOP" + local nameOffX = frameDB.nameTextX or 0 + local nameOffY = frameDB.nameTextY or -10 + + local nameText = mockFrame:CreateFontString(nil, "OVERLAY") + nameText:SetFont(nameFontPath, nameFontSize, "OUTLINE") + nameText:SetPoint(nameAnchor, mockFrame, nameAnchor, nameOffX, nameOffY) + nameText:SetText("Danders") + nameText:SetTextColor(0.18, 0.80, 0.44, 1) + container.nameText = nameText + + -- Health percentage (uses user's font + anchor settings) + local healthAnchor = frameDB.healthTextAnchor or "CENTER" + local healthOffX = frameDB.healthTextX or 0 + local healthOffY = frameDB.healthTextY or 4 + + if frameDB.showHealthText ~= false then + local hpText = mockFrame:CreateFontString(nil, "OVERLAY") + hpText:SetFont(healthFontPath, healthFontSize, "OUTLINE") + hpText:SetPoint(healthAnchor, mockFrame, healthAnchor, healthOffX, healthOffY) + hpText:SetText("72%") + hpText:SetTextColor(0.87, 0.87, 0.87, 1) + container.hpText = hpText + end + + -- Border overlay (used when border effect is active) — Stage 5.4: a + -- DF.Border widget covering the mock frame, mirroring the runtime. + container.borderOverlay = DF.Border:New(mockFrame, { frameLevelOffset = 5, layer = "OVERLAY" }) + + -- Click background — no-op in new UI (was used to deselect aura in old tile view) + local bgClick = CreateFrame("Button", nil, mockFrame) + bgClick:SetAllPoints() + bgClick:SetFrameLevel(mockFrame:GetFrameLevel() + 1) -- Below dots and indicators + bgClick:RegisterForClicks("LeftButtonUp") + + -- ======================================== + -- 9 ANCHOR POINT DOTS + -- ======================================== + wipe(anchorDots) + for anchorName, pos in pairs(ANCHOR_POSITIONS) do + local dotFrame = CreateFrame("Frame", nil, mockFrame) + dotFrame:SetSize(20, 20) + dotFrame:SetFrameLevel(mockFrame:GetFrameLevel() + 10) + + -- Position the dot zone + dotFrame:SetPoint(pos.ax, mockFrame, pos.ay, 0, 0) + + -- The visible dot + local dc = GetThemeColor() + local dot = dotFrame:CreateTexture(nil, "OVERLAY") + dot:SetSize(6, 6) + dot:SetPoint("CENTER", 0, 0) + dot:SetColorTexture(dc.r, dc.g, dc.b, 0.3) + dotFrame.dot = dot + + -- Hover zone (invisible button) -- also acts as drop target during drag + local hoverBtn = CreateFrame("Button", nil, dotFrame) + hoverBtn:SetAllPoints() + local capturedAnchorName = anchorName + hoverBtn:SetScript("OnEnter", function() + if dragState.isDragging then + -- Drag hover: enlarge and accent-color the dot + local tc = GetThemeColor() + dot:SetSize(14, 14) + dot:SetColorTexture(tc.r, tc.g, tc.b, 0.9) + dragState.dropAnchor = capturedAnchorName + -- Update hint to show target anchor + if S.dragHintText and dragState.auraInfo then + S.dragHintText:SetText(format(L["Place %s at %s"], dragState.auraInfo.display, capturedAnchorName)) + end + else + local tc = GetThemeColor() + dot:SetSize(10, 10) + dot:SetColorTexture(tc.r, tc.g, tc.b, 0.7) + end + end) + hoverBtn:SetScript("OnLeave", function() + if dragState.isDragging then + -- Revert to drag-active state (not default) + local tc = GetThemeColor() + dot:SetSize(10, 10) + dot:SetColorTexture(tc.r, tc.g, tc.b, 0.5) + dragState.dropAnchor = nil + -- Revert hint to generic drag message + if S.dragHintText and dragState.auraInfo then + S.dragHintText:SetText(format(L["Drop on an anchor point to place %s"], dragState.auraInfo.display)) + S.dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) + end + else + local tc = GetThemeColor() + dot:SetSize(6, 6) + dot:SetColorTexture(tc.r, tc.g, tc.b, 0.3) + end + end) + + dotFrame.anchorName = anchorName + dotFrame:Hide() -- Only visible during active drags + anchorDots[anchorName] = dotFrame + end + + -- Instructions with keyboard badge styling + local instrRows = { + { key = L["Click"], desc = L["an indicator on the frame to expand its settings"] }, + { key = L["Drag"], desc = L["a placed indicator to reposition it on the frame"] }, + { key = L["Right-click"], desc = L["a placed indicator to remove it from the frame"] }, + } + + local instrCount = #instrRows + for i, row in ipairs(instrRows) do + local rowBottomOffset = 10 + (instrCount - i) * 18 + + -- Key badge background + local badge = CreateFrame("Frame", nil, container, "BackdropTemplate") + badge:SetHeight(13) + badge:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 8, rowBottomOffset) + ApplyBackdrop(badge, C_ELEMENT, C_BORDER) + + local keyText = badge:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + keyText:SetPoint("CENTER", 0, 0) + keyText:SetText(row.key) + keyText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local keyWidth = keyText:GetStringWidth() + badge:SetWidth(max(keyWidth + 10, 20)) + + -- Description text (word-wrapped within container bounds) + local descText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + descText:SetPoint("LEFT", badge, "RIGHT", 5, 0) + descText:SetPoint("RIGHT", container, "RIGHT", -8, 0) + descText:SetWordWrap(true) + descText:SetJustifyH("LEFT") + descText:SetText(row.desc) + descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) + end + + -- ======================================== + -- PREVIEW SCALE SLIDER + -- ======================================== + local scaleSlider = GUI:CreateSlider(container, L["Preview Scale"], 0.75, 2.5, 0.05, adDB, "previewScale", + -- callback (on release) + function() + local s = adDB.previewScale or 1.0 + mockFrame:SetScale(s) + end, + -- lightweightUpdate (during drag) + function() + local s = adDB.previewScale or 1.0 + mockFrame:SetScale(s) + end + ) + scaleSlider:SetPoint("TOPLEFT", previewLabel, "BOTTOMLEFT", -4, -4) + scaleSlider:SetSize(220, 30) + + -- Drag-state hint text (shows contextual guidance during drag operations) + S.dragHintText = container:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(S.dragHintText, 9, "OUTLINE") + S.dragHintText:SetPoint("TOP", mockFrame, "BOTTOM", 0, -6) + S.dragHintText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) + S.dragHintText:SetText("") + + return container +end +P.CreateFramePreview = CreateFramePreview + +-- ============================================================ +-- TAB SYSTEM, SPELL PICKER & EFFECT CARDS (v4 redesign) +-- Functions for the new tabbed right panel, spell picker overlay, +-- and collapsible effect card rendering. +-- ============================================================ + +-- Forward declarations (mutually referencing functions) +-- (S.SwitchTab declared on the state table) +-- (S.BuildEffectsTab, S.BuildGlobalTab, S.BuildLayoutGroupsTab, S.BuildDebuffGroupsTab declared on the state table) +-- (S.CreateEffectCard declared on the state table) + +-- (S.spellPickerBlockedIDs declared on the state table) + -- cross-tab block; rebuilt per picker open) +local spellPickerBlockCache = {} -- auraName -> bool memo over S.spellPickerBlockedIDs + -- (wiped whenever the set is rebuilt) so blocked + -- checks don't re-resolve identity per row bind + +-- Cross-tab used check for one picker candidate: any of its identity IDs +-- (nil-spec identity on the Other tab — the naming contract's resolver — +-- else the spec identity) already tracked by the opposite pool. +local function IsCandidateCrossBlocked(auraName, spec) + if not S.spellPickerBlockedIDs or not next(S.spellPickerBlockedIDs) then return false end + local cached = spellPickerBlockCache[auraName] + if cached ~= nil then return cached end + local blocked = false + local f = DF:BuildADIdentityFilters(IsOtherTab() and nil or spec, auraName) + local map = f and f.includeSpellIDs + if map then + for id in pairs(map) do + if S.spellPickerBlockedIDs[id] then blocked = true; break end + end + end + spellPickerBlockCache[auraName] = blocked + return blocked +end + +-- Check if a specific aura has a frame-level effect of given type +local function HasFrameEffect(auraName, typeKey) + local auraCfg = CurrentAuraPool()[auraName] + return auraCfg and auraCfg[typeKey] ~= nil +end + +-- Clear all child frames and regions from the tab content area +local function ClearTabContent() + if not S.tabContentFrame then return end + local children = { S.tabContentFrame:GetChildren() } + for _, child in ipairs(children) do + child:Hide() + child:ClearAllPoints() + end + local regions = { S.tabContentFrame:GetRegions() } + for _, region in ipairs(regions) do + region:Hide() + end +end + +-- ── SHARED PICKER PLUMBING ── +-- Restore hook for the shared picker: fires on ANY close (back/ESC/ +-- programmatic/ancestor hide). Brings the tab surfaces back and rebuilds +-- the active tab when an add landed while the picker stayed open. +local function ADPickerClosed() + S.spellPickerBlockedIDs = nil -- recomputed on next open (memo wiped with it) + if S.tabBar then S.tabBar:Show() end + if S.tabScrollFrame then S.tabScrollFrame:Show() end + if S.adPickerDirty then + S.adPickerDirty = false + S.SwitchTab(S.activeTab or "effects") + end +end + +-- Open prelude shared by the three AD contexts: fresh cross-tab block set +-- (the memo over it persists across row rebinds while the picker is up), +-- hide the tab surfaces the overlay replaces, and open the shared picker +-- over the right panel. +local function OpenADPicker(opts) + S.spellPickerBlockedIDs = CrossPoolTrackedIDs() + wipe(spellPickerBlockCache) + S.adPickerDirty = false + if S.tabBar then S.tabBar:Hide() end + if S.tabScrollFrame then S.tabScrollFrame:Hide() end + opts.parent = S.rightPanel + opts.onClose = ADPickerClosed + -- Empty record list on My Buffs = unsupported/undetected spec: keep + -- the old picker's guidance instead of a bare "No results found". + -- (Other Buffs records are the full SpellDB — never empty.) + if not IsOtherTab() then + opts.emptyText = L["No trackable spells found for this spec.\n\nYou can select a different spec using the dropdown above."] + end + S.adPickerHandle = DF.FilterRegistry:OpenSpellPicker(opts) +end + +-- ── PICKER RECORDS ── +-- Shared-picker record list for the ACTIVE tab. My Buffs adapts the spec's +-- merged trackable list (curated Config entries + the SpellDB class pool + +-- class="ALL"); Other Buffs adapts the full SpellDB pool. includeAdHoc adds +-- configured "#" auras (group + trigger pickers — never in the +-- trackable pool). Records carry the SpellDB-compatible shape the shared +-- picker renders (id / class / cats plus display and icon overrides — the +-- icon override keeps the static IconTextures talent-guard) plus +-- `auraName`, the stable AD config key the row handlers write. +local function BuildADPickerRecords(includeAdHoc) + local isOther = IsOtherTab() + local spec = (not isOther) and ResolveSpec() or nil + local auras + if isOther then + auras = Adapter and Adapter.GetAllTrackableAuras and Adapter:GetAllTrackableAuras() + else + auras = spec and Adapter and Adapter:GetTrackableAuras(spec) + end + if not auras then return {} end + if includeAdHoc then + auras = WithConfiguredAdHocAuras(auras, spec) + end + local specInfo = spec and DF.AuraDesigner.SpecInfo[spec] + local lockClass = specInfo and specInfo.class + local R = DF.FilterRegistry + local tooltipOverrides = DF.AuraDesigner.TooltipSpellIDs + local specIDs = spec and DF.AuraDesigner.SpellIDs and DF.AuraDesigner.SpellIDs[spec] + local out = {} + for _, ai in ipairs(auras) do + -- Tooltip/canonical id: override table, else the spec whitelist, + -- else the pool entry's canonical id (same chain as the old cards) + local id = (tooltipOverrides and tooltipOverrides[ai.name]) + or (specIDs and specIDs[ai.name]) or ai.spellID + if type(id) == "table" then id = id[1] end -- rare multi-id entries + local rec = R and R.ByID and id and R.ByID[id] + out[#out + 1] = { + id = id or 0, + class = ai.class or lockClass or "ALL", + cats = rec and rec.cats or nil, + display = ai.display or ai.name, + icon = GetAuraIcon(spec, ai.name), + -- Letter/colour-swatch fallback for auras whose icon texture + -- doesn't resolve (the old card fallback) + iconColor = type(ai.color) == "table" and ai.color or nil, + auraName = ai.name, + } + end + return out +end + +-- Cross-tab block caption for one picker record (B2): a spell tracked by +-- the OPPOSITE pool renders dimmed, captioned with the tab it lives in, +-- and every add path is blocked. +local function ADCrossBlockText(rec) + if IsCandidateCrossBlocked(rec.auraName, ResolveSpec()) then + return IsOtherTab() and L["In My Buffs"] or L["In Any Buff"] + end + return nil +end + +-- ── SWITCH TAB ── +S.SwitchTab = function(tabKey) + -- Effects is frosted on the Debuffs tab (C2: category groups have no + -- placed indicators) — coerce to Layout Groups (belt-and-braces; the + -- sub-tab button is also frosted). + if tabKey == "effects" and IsDebuffTab() then + tabKey = "layout" + end + -- Preserve scroll position when refreshing the same tab + local prevTab = S.activeTab + local savedScroll = 0 + if tabKey == prevTab and S.tabScrollFrame then + savedScroll = S.tabScrollFrame:GetVerticalScroll() + end + + S.activeTab = tabKey + -- This switch rebuilds the tab anyway — skip the close hook's own + -- dirty rebuild so the tab isn't built twice. + S.adPickerDirty = false + CloseADPicker() + if GUI then GUI:CloseAllMenus() end -- an open dropdown (e.g. spec) must not outlive the tab + + for key, btn in pairs(tabButtons) do + btn:SetActive(key == tabKey) -- underline + accent/dim label (tab mode) + end + + ClearTabContent() + + if tabKey == "effects" then + S.BuildEffectsTab() + elseif tabKey == "layout" then + -- The Debuffs tab's Layout Groups list shows ONLY debuff category + -- groups; My Buffs / Other Buffs each build their OWN pool's + -- member+filter groups (S.BuildLayoutGroupsTab is pool-routed). + if IsDebuffTab() then + S.BuildDebuffGroupsTab() + else + S.BuildLayoutGroupsTab() + end + elseif tabKey == "global" then + S.BuildGlobalTab() + end + + if S.tabScrollFrame then + if tabKey == prevTab then + -- Clamp to new max scroll range (content may have changed height) + local maxScroll = S.tabScrollFrame:GetVerticalScrollRange() + S.tabScrollFrame:SetVerticalScroll(min(savedScroll, maxScroll)) + else + S.tabScrollFrame:SetVerticalScroll(0) + end + end +end + +-- ── MAIN POOL TAB SWITCH (B2/C2: My Buffs / Debuffs / Other Buffs) ── + +-- Grey/restore the sub-tabs: frosted (SetDisabled — stays mouse-enabled so +-- the tooltip can explain why; OnClick early-outs on dfDisabled). Effects +-- frosts on the Debuffs tab (category groups have no placed indicators). +-- Layout Groups is live on BOTH buff tabs (the Other tab hosts the flat +-- other-pool group store) — it never frosts anymore. +local function UpdateLayoutTabState() + local layoutBtn = tabButtons and tabButtons.layout + if layoutBtn and layoutBtn.SetDisabled then + layoutBtn:SetDisabled(false) + end + local effectsBtn = tabButtons and tabButtons.effects + if effectsBtn and effectsBtn.SetDisabled then + effectsBtn:SetDisabled(IsDebuffTab()) + end +end +P.UpdateLayoutTabState = UpdateLayoutTabState + +-- Grey the spec dropdown + swap its opener text on the Other and Debuffs +-- tabs (both pools are shared across specs); restore the live spec text on +-- My Buffs. +local function UpdateSpecDropdownState() + if not S.specDropdown then return end + if IsOtherTab() or IsDebuffTab() then + if S.specDropdown.SetDisplayOverride then + S.specDropdown:SetDisplayOverride(L["— (shared across specs)"]) + end + S.specDropdown:SetEnabled(false) + else + S.specDropdown:SetEnabled(true) + if S.specDropdown.SetDisplayOverride then + S.specDropdown:SetDisplayOverride(nil) + end + if S.specDropdownUpdate then S.specDropdownUpdate() end + end +end +P.UpdateSpecDropdownState = UpdateSpecDropdownState + +local function SetMainTab(tabKey) + if S.activeBuffTab == tabKey then return end + S.activeBuffTab = tabKey + -- Editor keys are pool-prefixed (B1) so cards can't collide across tabs, + -- but mirror the spec dropdown's behavior: a pool switch collapses all + -- expanded cards (wipe, not per-tab preservation). + wipe(expandedCards) + -- The shared picker captures its pool/effect at open time — never let + -- it survive a pool switch. + CloseADPicker() + if GUI then GUI:CloseAllMenus() end -- an open dropdown (e.g. spec) must not outlive the tab + for key, btn in pairs(mainTabButtons) do + btn:SetActive(key == tabKey) + end + UpdateSpecDropdownState() + UpdateLayoutTabState() + -- Effects is frosted on the Debuffs tab, so land on Layout Groups (the + -- tab's primary surface). Layout Groups is live on both buff tabs — no + -- coercion needed when arriving there. + if S.activeBuffTab == "debuffs" and S.activeTab == "effects" then + S.activeTab = "layout" + end + -- One entry point swaps every surface: RefreshPage → S.SwitchTab(S.activeTab) + -- (list, chips, add menu) + RefreshPlacedIndicators/RefreshPreviewEffects + -- (preview, drag targets) — all pool-routed through CurrentAuraPool. + DF:AuraDesigner_RefreshPage() +end +P.SetMainTab = SetMainTab + +-- ── ADD FROM PICKER (shared path) ── +-- What accepting a spell in the picker DOES: create the placed indicator +-- instance (or the frame-level type config, mode = "frame") and pre-expand +-- its effect card. Used by both the row handler and add-by-ID so the two +-- entry points can never drift apart. +local function AddPickedSpell(auraName, typeKey, mode) + -- Card keys embed the B1 pool prefix in the name segment + -- ("placed:other:#" / "frame::other:"). + if mode == "placed" then + local instance = CreateIndicatorInstance(auraName, typeKey) + if instance then + expandedCards["placed:" .. PoolKeyPrefix() .. auraName .. "#" .. instance.id] = true + end + else + EnsureTypeConfig(auraName, typeKey) + expandedCards["frame:" .. typeKey .. ":" .. PoolKeyPrefix() .. auraName] = true + end + -- Structural change: drive the LIVE frames, not just the editor. The callers only run + -- RefreshPlacedIndicators / RefreshPreviewEffects (editor chips + preview canvas), so + -- without this a freshly added indicator never builds its live container until some other + -- action (move / eye toggle / reload) fires ForceRefreshAllFrames. Mirrors AddSpellToGroup. + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end +end + +-- ── ADD TO LAYOUT GROUP ("group" picker context) ── +-- One click on a row's Icon/Square button (or the ID row's, for add-by-ID): +-- create a NEW placed indicator of that type and enrol it in the target group. +-- The picker stays open for multi-add, and the same spell can be added again — +-- every add mints a fresh indicator id, so AddGroupMember's (auraName, +-- indicatorID) dedup never blocks it. skipEcho lets add-by-ID substitute its +-- own unknown-ID echo. Refresh chain mirrors the group card's member ✕. +local function AddSpellToGroup(groupID, auraName, display, typeKey, skipEcho, picker) + if not groupID then return end + local instance = CreateIndicatorInstance(auraName, typeKey) + if not instance then return end + AddGroupMember(groupID, auraName, instance.id) + S.adPickerDirty = true -- layout tab behind the picker is stale; rebuilt on close + if not skipEcho and picker then + local typeLabel = S.PLACED_TYPE_LABELS[typeKey] or typeKey + picker:Echo(format(L["Added %s."], + format("%s (%s)", display or auraName, typeLabel))) + end + RefreshPlacedIndicators() + -- Structural change: same full refresh as the member ✕ + -- (new indicator container + group positions + buff-row dedup). + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end +end + +-- ── ADD BY ID (shared picker ID row) ── +-- Snap known ids to their pool/curated record (then behave exactly like +-- clicking that spell's row — same AddPickedSpell / AddSpellToGroup paths); +-- unknown ids become an ad-hoc "#" aura whose key IS its identity +-- (S.CleanupAdHocAura drops the config again once its last effect is +-- removed). The picker stays open (echo confirms), so several ids can be +-- added in a row. The shared picker has already validated the digits and +-- normalized leading zeros; idText is that validated digit STRING. Returns +-- truthy when the add landed (the picker clears its ID box on that). +local function ADAddByID(idNum, idText, picker, mode, typeKey, groupID) + local isOther = IsOtherTab() + local spec = ResolveSpec() + -- The Other Buffs pool is spec-independent — no spec required there. + if not spec and not isOther then return end + + -- Snap. My Buffs: the spec's curated tables first (alt ids included), + -- then the SpellDB (R.ByID indexes canonical + alt ids), else ad-hoc. + -- Other Buffs: SpellDB ONLY — the B1 naming contract (other-pool keys + -- are SpellDB rec.n or ad-hoc "#"; curated internal names don't + -- resolve with a nil spec). + local auraName + if not isOther then + local alts = DF.AuraDesigner.AlternateSpellIDs and DF.AuraDesigner.AlternateSpellIDs[spec] + if alts and alts[idNum] then auraName = alts[idNum] end + if not auraName then + local specIDs = DF.AuraDesigner.SpellIDs and DF.AuraDesigner.SpellIDs[spec] + if specIDs then + for name, id in pairs(specIDs) do + if id == idNum then auraName = name; break end + if type(id) == "table" then -- rare multi-id entries + for _, sub in ipairs(id) do + if sub == idNum then auraName = name; break end + end + if auraName then break end + end + end + end + end + end + if not auraName then + local R = DF.FilterRegistry + local rec = R and R.ByID and R.ByID[idNum] + if rec then auraName = rec.n end + end + + local isAdHoc = not auraName + -- Key from the validated TEXT, not tonumber output — number formatting + -- must never leak into config keys (AdHocSpellID parses "^#(%d+)$"). + if isAdHoc then auraName = "#" .. idText end + + -- Cross-tab block (B2): the spell — snapped name's FULL identity set, + -- or the raw id for ad-hoc — is already tracked by the OPPOSITE pool. + -- Checked before the group branch so group adds are blocked too. + local crossBlocked = false + if S.spellPickerBlockedIDs and next(S.spellPickerBlockedIDs) then + if S.spellPickerBlockedIDs[idNum] then + crossBlocked = true + elseif not isAdHoc then + crossBlocked = IsCandidateCrossBlocked(auraName, spec) + end + end + if crossBlocked then + picker:Echo(isOther and L["Already tracked in My Buffs."] or L["Already tracked in Any Buff."]) + return + end + + -- Display name: the trackable pool entry when it has one (curated + -- display or localized SpellDB name), else the raw key. + local display = auraName + if not isAdHoc then + if isOther then + display = OtherPoolDisplayName(auraName) + else + local trackable = Adapter and Adapter:GetTrackableAuras(spec) + if trackable then + for _, info in ipairs(trackable) do + if info.name == auraName then display = info.display or auraName; break end + end + end + end + end + + -- Group context: no already-used gate (a spell can hold several + -- indicators in one group). AddSpellToGroup echoes and refreshes. + if mode == "group" then + AddSpellToGroup(groupID, auraName, display, typeKey or "icon", isAdHoc, picker) + if isAdHoc then + picker:Echo(format(L["Added #%d as an unknown spell ID — name and icon will show if the ID is valid."], idNum)) + picker:RefreshRecords() -- the new ad-hoc aura gets a row of its own + end + return true + end + + local alreadyUsed + if mode == "placed" then + alreadyUsed = IsAuraTypePlaced(auraName, typeKey) + else + alreadyUsed = HasFrameEffect(auraName, typeKey) + end + if alreadyUsed then + picker:Echo(L["Already added."]) + return + end + + AddPickedSpell(auraName, typeKey, mode) + S.adPickerDirty = true + if isAdHoc then + picker:Echo(format(L["Added #%d as an unknown spell ID — name and icon will show if the ID is valid."], idNum)) + else + picker:Echo(format(L["Added %s."], display)) + end + picker:Refresh() -- the row flips to its blocked state + RefreshPlacedIndicators() -- live preview updates behind the picker + RefreshPreviewEffects() + return true +end + +-- ── OPEN: ADD INDICATOR (placed + frame contexts) ── +-- typeKey: "icon"|"square"|"bar" (placed) or "border"|"healthbar"|etc. +-- (frame); mode: "placed" or "frame". Single-pick — choosing a spell +-- creates the indicator (or frame-level type config), closes the picker +-- and lands on its expanded effect card. My Buffs locks the picker to the +-- resolved spec's class (class dropdown hidden, records limited to the +-- class + "ALL"); Other Buffs offers the full database with both filters. +local function OpenIndicatorPicker(typeKey, mode) + local isOther = IsOtherTab() + local spec = (not isOther) and ResolveSpec() or nil + local specInfo = spec and DF.AuraDesigner.SpecInfo[spec] + local badgeColor = BADGE_COLORS[typeKey] or BADGE_COLORS.icon + local title + if mode == "frame" then + title = format(L["Select trigger for %s"], S.FRAME_LEVEL_LABELS[typeKey] or typeKey) + else + title = L["Select a spell"] + end + OpenADPicker({ + title = title, + subtitle = S.PLACED_TYPE_LABELS[typeKey] or S.FRAME_LEVEL_LABELS[typeKey] or typeKey, + subtitleColor = badgeColor, + records = function() return BuildADPickerRecords(false) end, + classLock = (not isOther) and specInfo and specInfo.class or nil, + isBlocked = function(rec) + local cross = ADCrossBlockText(rec) + if cross then return cross end + if mode == "placed" then + if IsAuraTypePlaced(rec.auraName, typeKey) then return L["Placed"] end + elseif HasFrameEffect(rec.auraName, typeKey) then + return L["Active"] + end + return nil + end, + rowActions = { + { + label = L["Add"], -- labels the ID-row button; rows are click-to-add + handler = function(rec, _, picker) + AddPickedSpell(rec.auraName, typeKey, mode) + picker:Close() + S.SwitchTab("effects") + RefreshPlacedIndicators() + RefreshPreviewEffects() + end, + }, + }, + allowAddByID = true, + onAddByID = function(idNum, _, picker, idText) + return ADAddByID(idNum, idText, picker, mode, typeKey, nil) + end, + }) +end + +-- ── OPEN: ADD TO LAYOUT GROUP ── +-- Every row carries Icon / Square buttons that create the indicator and +-- enrol it in the target group in one click (the picker stays open for +-- adding several in a row); the ID row gets the same two buttons, Enter +-- defaulting to Icon. Records include the pool's configured ad-hoc +-- "#" auras — the old picker offered them too. +local function OpenGroupSpellPicker(groupID) + local isOther = IsOtherTab() + local spec = (not isOther) and ResolveSpec() or nil + local specInfo = spec and DF.AuraDesigner.SpecInfo[spec] + local grp = groupID and GetLayoutGroupByID(groupID) + OpenADPicker({ + title = L["Select a spell"], + subtitle = grp and grp.name or "", + subtitleColor = GetThemeColor(), + records = function() return BuildADPickerRecords(true) end, + classLock = (not isOther) and specInfo and specInfo.class or nil, + -- Duplicates are allowed (each add is its own indicator instance), + -- so only the cross-tab block dims a row. + isBlocked = ADCrossBlockText, + rowActions = { + { + label = S.PLACED_TYPE_LABELS.icon or "Icon", + color = BADGE_COLORS.icon, + typeKey = "icon", + handler = function(rec, _, picker) + AddSpellToGroup(groupID, rec.auraName, rec.display, "icon", false, picker) + end, + }, + { + label = S.PLACED_TYPE_LABELS.square or "Square", + color = BADGE_COLORS.square, + typeKey = "square", + handler = function(rec, _, picker) + AddSpellToGroup(groupID, rec.auraName, rec.display, "square", false, picker) + end, + }, + }, + allowAddByID = true, + onAddByID = function(idNum, action, picker, idText) + return ADAddByID(idNum, idText, picker, "group", (action and action.typeKey) or "icon", groupID) + end, + }) +end +P.OpenGroupSpellPicker = OpenGroupSpellPicker + +-- ── CREATE EFFECT CARD ── +-- Creates a collapsible card for one effect in the effects list. +-- Returns the new yPos after the card. +S.CreateEffectCard = function(parent, yPos, effect) + local isPlaced = (effect.source == "placed") + -- B1 key scheme: the pool prefix rides the NAME segment, so the two + -- pools' expandedCards entries can never collide. + local keyPrefix = PoolKeyPrefix() + local cardKey + if isPlaced then + cardKey = "placed:" .. keyPrefix .. effect.auraName .. "#" .. effect.indicatorID + else + cardKey = "frame:" .. effect.typeKey .. ":" .. keyPrefix .. effect.auraName + end + + local isExpanded = expandedCards[cardKey] or false + + -- ── CARD + HEADER ── + local card, header, chevron = CreateCardShell(parent, { + yPos = yPos, + expanded = isExpanded, + borderColor = {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}, + }) + + -- Spell icon (small, before type badge). Other-pool records resolve + -- icon/identity spec-independently (nil spec → ad-hoc / SpellDB fallback). + local spec = IsOtherTab() and nil or ResolveSpec() + local iconTex = GetAuraIcon(spec, effect.auraName) + local spellIcon = header:CreateTexture(nil, "ARTWORK") + spellIcon:SetSize(20, 20) + spellIcon:SetPoint("LEFT", chevron, "RIGHT", 6, 0) + if iconTex then + spellIcon:SetTexture(iconTex) + spellIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + else + -- Color swatch fallback using aura color + local trackable3 = spec and Adapter and Adapter:GetTrackableAuras(spec) + local auraColor = nil + if trackable3 then + for _, ai in ipairs(trackable3) do + if ai.name == effect.auraName then auraColor = ai.color; break end + end + end + if auraColor then + spellIcon:SetColorTexture(auraColor[1] * 0.5, auraColor[2] * 0.5, auraColor[3] * 0.5, 1) + else + spellIcon:SetColorTexture(0.25, 0.25, 0.25, 1) + end + end + + -- Type badge + local badgeColor = BADGE_COLORS[effect.typeKey] or BADGE_COLORS.icon + local typeLabel = isPlaced + and (S.PLACED_TYPE_LABELS[effect.typeKey] or effect.typeKey) + or (S.FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey) + + local badgeBg = CreateFrame("Frame", nil, header, "BackdropTemplate") + badgeBg:SetHeight(16) + badgeBg:SetPoint("LEFT", spellIcon, "RIGHT", 4, 0) + ApplyBackdrop(badgeBg, + {r = badgeColor.r * 0.20, g = badgeColor.g * 0.20, b = badgeColor.b * 0.20, a = 1}, + {r = badgeColor.r * 0.45, g = badgeColor.g * 0.45, b = badgeColor.b * 0.45, a = 0.8}) + + local badgeText = badgeBg:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(badgeText, 8, "OUTLINE") + badgeText:SetPoint("CENTER", 0, 0) + badgeText:SetText(typeLabel) + badgeText:SetTextColor(1, 1, 1) + badgeBg:SetWidth(max(badgeText:GetStringWidth() + 12, 32)) + + -- Warning badge for auras with API-level tracking limitations + -- (positioned to the right of the type badge) + local warnKey = GetAuraWarningKey(spec, effect.auraName) + AttachWarningBadge(header, warnKey, { + point = "LEFT", + relativeTo = badgeBg, + relativePoint = "RIGHT", + offsetX = 4, + offsetY = 0, + size = 16, + }) + + -- Aura name + anchor/trigger/group info + local infoStr = effect.displayName + local indicatorGroup = nil -- layout group this indicator belongs to + if isPlaced then + indicatorGroup = GetIndicatorLayoutGroup(effect.auraName, effect.indicatorID) + if indicatorGroup then + infoStr = infoStr .. " - " .. indicatorGroup.name + elseif effect.anchor then + infoStr = infoStr .. " - " .. (OPTS.ANCHOR_OPTIONS[effect.anchor] or effect.anchor) + end + else + -- Show trigger count for frame-level effects + local triggers = GetFrameEffectTriggers(effect.auraName, effect.typeKey) + if #triggers > 1 then + -- No "(AND)" suffix: the operator toggle is gone (12.1 cannot evaluate + -- triggers together read-free), so multiple triggers always mean ANY/OR. + infoStr = infoStr .. " - " .. format(L["+%d triggers"], #triggers - 1) + end + end + -- Other Buffs: surface the per-effect Others Only state on the collapsed + -- header (prototype's "Others only" chip, as a text suffix). + if IsOtherTab() and effect.config and effect.config.othersOnly then + infoStr = infoStr .. " - " .. L["Others Only"] + end + local infoText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + if warnKey and header.dfWarningBadge and header.dfWarningBadge:IsShown() then + infoText:SetPoint("LEFT", header.dfWarningBadge, "RIGHT", 6, 0) + else + infoText:SetPoint("LEFT", badgeBg, "RIGHT", 6, 0) + end + -- Right inset clears the action icons: eye only (grouped) or eye + ✕. + infoText:SetPoint("RIGHT", header, "RIGHT", indicatorGroup and -30 or -52, 0) + infoText:SetMaxLines(1) + infoText:SetText(infoStr) + if indicatorGroup then + -- Use dimmed text for grouped indicators — they're managed by the group + infoText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + else + infoText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + + -- Delete button — hidden for grouped indicators (managed by layout group) + local delBtn + if not indicatorGroup then + delBtn = GUI:CreateCloseButton(header, { + size = 22, + onClick = function() + if isPlaced then + RemoveIndicatorInstance(effect.auraName, effect.indicatorID) + else + local auraCfg = CurrentAuraPool()[effect.auraName] + if auraCfg then auraCfg[effect.typeKey] = nil end + S.CleanupAdHocAura(effect.auraName) -- drop emptied ad-hoc "#" entries + end + expandedCards[cardKey] = nil + S.SwitchTab("effects") + RefreshPlacedIndicators() + RefreshPreviewEffects() + -- Structural change: the container must rebuild AND the buff-row + -- dedup union shrinks (deleted = no longer tracked), so run the + -- full refresh path (mirror the eye toggle) — without this the + -- deleted indicator's buff-row icon stays suppressed until an + -- unrelated rebuild. + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end, + }) + delBtn:SetPoint("RIGHT", -4, 0) + delBtn:SetFrameLevel(header:GetFrameLevel() + 2) + end + + -- Eye icon (visibility toggle) — left of the ✕; grouped indicators keep it + -- even though their ✕ is hidden. Asset + toggle idiom mirror Text Designer's + -- eye (TextDesigner/Options.lua): visibility / visibility_off from + -- Media/Icons, bright when shown, dim when hidden, hover brighten. + -- State lives on the raw config table: enabled == false is hidden; + -- nil/true (legacy records) is shown. + do + local cfgTable = effect.config + local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) + if delBtn then + eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) + else + eyeBtn:SetPoint("RIGHT", header, "RIGHT", -6, 0) + end + local function shown() return not cfgTable or cfgTable.enabled ~= false end + -- SetGlyph makes the state colour the new REST colour, so OnLeave + -- restores the state; hover is suppressed while hidden. + local function updateEyeIcon() + if shown() then + eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) + else + eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) + end + eyeBtn:SetGlyphHover(shown()) + end + updateEyeIcon() + eyeBtn:RegisterForClicks("LeftButtonUp") + eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) + eyeBtn:SetScript("OnClick", function() + if not cfgTable then return end + cfgTable.enabled = (cfgTable.enabled == false) and true or false + updateEyeIcon() + -- Sound rides the same flag as its "Enable Sound Alert" checkbox — + -- stop a playing alert immediately when hidden (mirror that checkbox). + if effect.typeKey == "sound" and cfgTable.enabled == false + and DF.AuraDesigner.SoundEngine then + DF.AuraDesigner.SoundEngine:StopAura(effect.auraName) + end + -- Structural change: the factory must tear down / stand up the + -- container and the buff-row dedup union changes (hidden = not + -- tracked), so run the full refresh path (mirror the enable toggle). + S.SwitchTab("effects") + RefreshPlacedIndicators() + RefreshPreviewEffects() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end) + + -- Hidden rows dim (name/icon), like Text Designer's disabled elements + if not shown() then + spellIcon:SetAlpha(0.4) + infoText:SetAlpha(0.5) + end + end + + -- Header click → toggle expansion + header:SetScript("OnClick", function() + expandedCards[cardKey] = not expandedCards[cardKey] + S.SwitchTab("effects") + end) + header:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + header:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + local totalCardH = 30 + + -- ── BODY (only when expanded) ── + if isExpanded then + local body = CreateFrame("Frame", nil, card, "BackdropTemplate") + body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) + body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) + ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, + {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3}) + + -- Create the appropriate proxy + local proxy + if isPlaced then + proxy = CreateInstanceProxy(effect.auraName, effect.indicatorID) + else + proxy = CreateProxy(effect.auraName, effect.typeKey) + end + + -- Build type-specific widgets (derive width from parent scroll frame) + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 + if bodyWidth < 100 then bodyWidth = 240 end + + local triggersH = 0 + + -- ── TRIGGER TAGS (frame-level effects only) ── + if not isPlaced then + local triggers = GetFrameEffectTriggers(effect.auraName, effect.typeKey) + local trigContainer = CreateFrame("Frame", nil, body) + trigContainer:SetPoint("TOPLEFT", 8, -12) + trigContainer:SetPoint("RIGHT", body, "RIGHT", -8, 0) + + local trigLabel = trigContainer:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(trigLabel, 9, "") + trigLabel:SetPoint("TOPLEFT", 0, 0) + trigLabel:SetText(L["TRIGGERED BY"]) + trigLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- AND/OR operator toggle (only shown with 2+ triggers) + -- (No multi-trigger ALL/ANY operator button: evaluating every trigger together + -- needs a read the 12.1 aura system cannot do for secret-anchored triggers, so + -- it was permanently frosted. Removed 2026-07-25 -- triggerOperator was never + -- read by the render path either, only by this editor's own label, so the + -- toggle changed nothing. Triggers combine as ANY/OR. The tags stay editable.) + + -- Build display name lookup for tags. Other-pool trigger names are + -- SpellDB names / ad-hoc keys — resolved live per tag below. + local isOtherCard = IsOtherTab() + local spec = (not isOtherCard) and ResolveSpec() or nil + local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) + local displayNames = {} + if trackable then + for _, info in ipairs(trackable) do + displayNames[info.name] = info.display + end + end + if isOtherCard then + setmetatable(displayNames, { __index = function(_, name) + return OtherPoolDisplayName(name) + end }) + end + + -- Tag flow layout + local TAG_H = 20 + local TAG_GAP = 4 + local TAG_ROW_GAP = 3 + local tagX, tagY = 0, -(14 + 6) -- below label + local canRemove = #triggers > 1 + + for ti, trigName in ipairs(triggers) do + local tagFrame = CreateFrame("Frame", nil, trigContainer, "BackdropTemplate") + tagFrame:SetHeight(TAG_H) + + local tagText = tagFrame:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(tagText, 9, "") + tagText:SetPoint("LEFT", 6, 0) + tagText:SetText(displayNames[trigName] or trigName) + tagText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local tagW = tagText:GetStringWidth() + 12 + if canRemove then tagW = tagW + 16 end -- room for × button + tagW = max(tagW, 40) + + -- Wrap to next row if needed + local containerW = trigContainer:GetWidth() + if containerW < 50 then containerW = bodyWidth - 16 end + if tagX > 0 and (tagX + tagW) > containerW then + tagX = 0 + tagY = tagY - (TAG_H + TAG_ROW_GAP) + end + + tagFrame:SetPoint("TOPLEFT", trigContainer, "TOPLEFT", tagX, tagY) + tagFrame:SetWidth(tagW) + ApplyBackdrop(tagFrame, + {r = 0.14, g = 0.14, b = 0.17, a = 1}, + {r = 0.30, g = 0.30, b = 0.35, a = 0.8}) + + -- Remove × button on each tag (unless it's the last one) + if canRemove then + local capturedTrigName = trigName + -- Shared red-at-rest "×" (tone="danger") on each removable tag. + local removeBtn = DF.GUI:CreateCloseButton(tagFrame, { + size = 14, + tone = "danger", + onClick = function() + RemoveFrameEffectTrigger(effect.auraName, effect.typeKey, capturedTrigName) + S.SwitchTab("effects") + RefreshPreviewEffects() + end, + }) + removeBtn:SetPoint("RIGHT", -2, 0) + end + + tagX = tagX + tagW + TAG_GAP + end + + -- "+ Add Trigger" button + local addTrigW = 80 + if tagX > 0 and (tagX + addTrigW) > (bodyWidth - 16) then + tagX = 0 + tagY = tagY - (TAG_H + TAG_ROW_GAP) + end + local addTrigBtn = CreateFrame("Button", nil, trigContainer, "BackdropTemplate") + addTrigBtn:SetPoint("TOPLEFT", trigContainer, "TOPLEFT", tagX, tagY) + GUI:StyleButton(addTrigBtn, { width = addTrigW, height = TAG_H, primary = true, accent = { r = 0.25, g = 0.40, b = 0.25 }, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 11 }, text = L["Add Trigger"] }) + GUI:SetSettingsFont(addTrigBtn.Text, 9, "") + addTrigBtn.Text:SetTextColor(0.5, 0.8, 0.5) + addTrigBtn.Icon:SetVertexColor(0.5, 0.8, 0.5) + + -- Trigger picker: the shared spell database picker, single-pick. + -- My Buffs locks it to the resolved spec's class; Other Buffs + -- offers the full database (the old plain dropdown restricted + -- Other-tab triggers to already-configured auras only because + -- the full DB was unusable without search/filters — the shared + -- picker has both, so the restriction is lifted). Records also + -- include the pool's configured ad-hoc "#" auras. Rows + -- already in the effect's trigger list render with the dimmed + -- check ("already added"). + addTrigBtn:SetScript("OnClick", function() + -- Pin the pool at OPEN time (pool pinning carried over from + -- the old floating dropdown): a pick must keep writing the + -- trigger into the pool this card's record lives in, no + -- matter how the surrounding UI state moves while the + -- picker is up (the record exists, so the read accessor + -- returns the real table, never EMPTY_POOL). + local capturedPool = CurrentAuraPool() + local isOtherTrig = IsOtherTab() + local trigSpec = (not isOtherTrig) and ResolveSpec() or nil + local trigSpecInfo = trigSpec and DF.AuraDesigner.SpecInfo[trigSpec] + + local currentTriggers = GetFrameEffectTriggers(effect.auraName, effect.typeKey) + local trigLookup = {} + for _, t in ipairs(currentTriggers) do trigLookup[t] = true end + + OpenADPicker({ + title = format(L["Select trigger for %s"], S.FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey), + subtitle = effect.displayName, + records = function() return BuildADPickerRecords(true) end, + classLock = (not isOtherTrig) and trigSpecInfo and trigSpecInfo.class or nil, + isBlocked = function(rec) + return trigLookup[rec.auraName] and true or nil + end, + rowActions = { + { + handler = function(rec, _, picker) + AddFrameEffectTrigger(effect.auraName, effect.typeKey, rec.auraName, capturedPool) + picker:Close() + S.SwitchTab("effects") + RefreshPreviewEffects() + end, + }, + }, + }) + end) + + triggersH = -(tagY) + TAG_H + 8 -- total height of trigger section + trigContainer:SetHeight(triggersH) + + -- Border mode toggle (border effects only) + if effect.typeKey == "border" then + local auraCfgBM = CurrentAuraPool()[effect.auraName] + local typeCfgBM = auraCfgBM and auraCfgBM[effect.typeKey] + local isCustom = typeCfgBM and typeCfgBM.borderMode == "custom" + + local bmContainer = CreateFrame("Frame", nil, body) + bmContainer:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 10)) + bmContainer:SetPoint("RIGHT", body, "RIGHT", -8, 0) + bmContainer:SetHeight(26) + + local bmLabel = bmContainer:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(bmLabel, 9, "") + bmLabel:SetPoint("LEFT", 0, 0) + bmLabel:SetText(L["Border Mode:"]) + bmLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Shared button + local sharedBtn = CreateFrame("Button", nil, bmContainer, "BackdropTemplate") + sharedBtn:SetPoint("LEFT", bmLabel, "RIGHT", 6, 0) + + local sharedText = sharedBtn:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(sharedText, 9, "") + sharedText:SetPoint("CENTER", 0, 0) + sharedText:SetText(L["Shared"]) + local sharedW = sharedText:GetStringWidth() + 16 + if sharedW < 50 then sharedW = 50 end + -- Shared styler: rest + accent-wash hover + SetActive selection state. + -- Keep the manual (small) label; size to the computed text width. + GUI:StyleButton(sharedBtn, { width = sharedW, height = 20 }) + + -- Custom button + local customBtn = CreateFrame("Button", nil, bmContainer, "BackdropTemplate") + customBtn:SetPoint("LEFT", sharedBtn, "RIGHT", 4, 0) + + local customText = customBtn:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(customText, 9, "") + customText:SetPoint("CENTER", 0, 0) + customText:SetText(L["Custom"]) + local customW = customText:GetStringWidth() + 16 + if customW < 50 then customW = 50 end + GUI:StyleButton(customBtn, { width = customW, height = 20 }) + + -- Drive the selection state via the shared styler's SetActive (active = + -- toned accent border + subtle accent fill). Keep a bright/dim label cue. + local function StyleBorderModeButtons(customActive) + sharedBtn:SetActive(not customActive) + customBtn:SetActive(customActive) + if customActive then + customText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + sharedText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + else + sharedText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + customText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + StyleBorderModeButtons(isCustom) + + sharedBtn:SetScript("OnClick", function() + local cfg = EnsureTypeConfig(effect.auraName, effect.typeKey) + cfg.borderMode = nil -- shared is default + S.SwitchTab("effects") + RefreshPreviewEffects() + end) + customBtn:SetScript("OnClick", function() + local cfg = EnsureTypeConfig(effect.auraName, effect.typeKey) + cfg.borderMode = "custom" + S.SwitchTab("effects") + RefreshPreviewEffects() + end) + + -- Tooltips via HookScript so they compose with the styler's hover wash. + sharedBtn:HookScript("OnEnter", function() + GUI:ShowTooltip(sharedBtn, { + title = L["Shared Border"], + lines = { + L["Uses a single border per frame. Highest priority wins."], + }, + }) + end) + sharedBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) + customBtn:HookScript("OnEnter", function() + GUI:ShowTooltip(customBtn, { + title = L["Custom Border"], + lines = { + L["Gets its own independent border overlay. Multiple custom borders can be visible at the same time."], + }, + }) + end) + customBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) + + triggersH = triggersH + 36 + end + + -- Priority slider (frame-level effects only — resolves conflicts when + -- multiple auras set the same frame effect, e.g. two health bar colors) + local auraProxy = CreateAuraProxy(effect.auraName) + local priSlider = GUI:CreateSlider(body, L["Priority"], 1, 10, 1, auraProxy, "priority") + -- Extra gap above (was +4) so the slider isn't squished against the + -- triggers / Add Trigger row, plus a little breathing room below before + -- the effect's Appearance group (increment 54 → 68 → 84 with the note). + -- x=8 matches the "TRIGGERED BY" section above (Options.lua trigContainer) + -- so the Priority slider + note line up with the card's other elements. + priSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 14)) + priSlider:SetWidth(bodyWidth - 16) + -- Direction note in the standard GUI label style (dim, wrapped) so it + -- matches every other settings note: HIGHER number = higher priority. + local priNote = GUI:CreateLabel(body, L["Higher priority wins"], bodyWidth - 16) + priNote:SetPoint("TOPLEFT", priSlider, "BOTTOMLEFT", 0, -2) + triggersH = triggersH + 84 + end + + -- ── OTHERS ONLY (Other Buffs tab; placed AND frame-level effects) ── + -- Not offered for sound: the on-apply sound path has no caster filter + -- (the sound card carries an explanatory banner instead, see + -- BuildTypeContent). Writes instance.othersOnly / typeCfg.othersOnly + -- through the pool-pinned proxy; the filter string ("HELPFUL|!PLAYER") + -- binds at container build, so toggling is STRUCTURAL (B1 folds it + -- into every struct sig → the factory Rebuilds). + if IsOtherTab() and effect.typeKey ~= "sound" then + local ooCb = GUI:CreateCheckbox(body, L["Others Only"], proxy, "othersOnly", function() + DF:AuraDesigner_RefreshPage() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end) + ooCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 12)) + ooCb:SetWidth(bodyWidth - 16) + ooCb.tooltip = L["Only show this effect for other players' casts of the buff."] + triggersH = triggersH + 34 + end + + local _, bodyH = BuildTypeContent(body, effect.typeKey, effect.auraName, bodyWidth, proxy, triggersH, indicatorGroup, effect.indicatorID) + + -- Bottom collapse bar for the indicator card + local collapseBarH = 14 + local collapseBar = CreateFrame("Button", nil, body) + collapseBar:SetHeight(collapseBarH) + collapseBar:SetPoint("BOTTOMLEFT", body, "BOTTOMLEFT", 1, 1) + collapseBar:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -1, 1) + + local barBg = collapseBar:CreateTexture(nil, "BACKGROUND") + barBg:SetAllPoints() + barBg:SetColorTexture(1, 1, 1, 0.03) + + local barIcon = collapseBar:CreateTexture(nil, "OVERLAY") + barIcon:SetSize(8, 8) + barIcon:SetPoint("CENTER", 0, 0) + barIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") + barIcon:SetVertexColor(1, 1, 1, 0.3) + + collapseBar:SetScript("OnEnter", function() + barBg:SetColorTexture(1, 1, 1, 0.06) + barIcon:SetVertexColor(1, 1, 1, 0.6) + end) + collapseBar:SetScript("OnLeave", function() + barBg:SetColorTexture(1, 1, 1, 0.03) + barIcon:SetVertexColor(1, 1, 1, 0.3) + end) + collapseBar:SetScript("OnClick", function() + expandedCards[cardKey] = false + S.SwitchTab("effects") + end) + + local contentH = (bodyH or 50) + triggersH + collapseBarH + body:SetHeight(contentH) + totalCardH = totalCardH + contentH + end + + card:SetHeight(totalCardH) + return yPos - totalCardH - 5 +end + +-- ── BUILD EFFECTS TAB ── +S.BuildEffectsTab = function() + if not S.tabContentFrame then return end + local parent = S.tabContentFrame + local yPos = -10 + local tc = GetThemeColor() + + -- "+ Add Indicator" button (prominent, theme-colored border) + local addBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + addBtn:SetHeight(32) + addBtn:SetPoint("TOPLEFT", 8, yPos) + addBtn:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + -- Shared primary CTA: accent fill + white label via the styler (was a bespoke + -- fontstring, which is why AD's and TD's hero labels didn't match). + GUI:StyleButton(addBtn, { height = 32, primary = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 14 }, text = L["Add Indicator"], font = "DFFontHighlight" }) + + -- Dropdown menu for add button + local menuFrame = CreateFrame("Frame", nil, addBtn, "BackdropTemplate") + menuFrame:SetPoint("TOPLEFT", addBtn, "BOTTOMLEFT", 0, -2) + menuFrame:SetPoint("TOPRIGHT", addBtn, "BOTTOMRIGHT", 0, -2) + menuFrame:SetFrameStrata("DIALOG") + menuFrame:SetFrameLevel(100) + ApplyBackdrop(menuFrame, {r = 0.10, g = 0.10, b = 0.10, a = 0.98}, C_BORDER) + menuFrame:Hide() + menuFrame:EnableMouse(true) + + local PLACED_ITEMS = { + { label = L["Icon"], type = "icon" }, + { label = L["Square"], type = "square" }, + { label = L["Bar"], type = "bar" }, + } + local FRAME_ITEMS = { + { label = L["Border"], type = "border" }, + { label = L["Health Bar Color"], type = "healthbar" }, + { label = L["Background Color"], type = "background" }, + { label = L["Name Text Color"], type = "nametext" }, + { label = L["Health Text Color"], type = "healthtext" }, + { label = L["Sound Alert"], type = "sound" }, + } + + local my = -4 + local MENU_ROW_H = 24 + + -- The menu is two identical sections -- a small dim heading, then one row + -- per entry coloured by its type badge -- and both were written out in + -- full. `my` is the running cursor, so these close over it. + local function AddMenuSection(heading, items, scope) + local hdr = menuFrame:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(hdr, 9, "") + hdr:SetPoint("TOPLEFT", 10, my) + hdr:SetText(heading) + hdr:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + my = my - 14 + + for _, item in ipairs(items) do + local menuBtn = CreateFrame("Button", nil, menuFrame) + menuBtn:SetHeight(MENU_ROW_H) + menuBtn:SetPoint("TOPLEFT", 4, my) + menuBtn:SetPoint("RIGHT", menuFrame, "RIGHT", -4, 0) + local bc = BADGE_COLORS[item.type] + local lbl = menuBtn:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(lbl, 10, "") + lbl:SetPoint("LEFT", 8, 0) + lbl:SetText(item.label) + lbl:SetTextColor(bc.r, bc.g, bc.b) + local hl = menuBtn:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(1, 1, 1, 0.05) + local capturedType = item.type + menuBtn:SetScript("OnClick", function() + menuFrame:Hide() + OpenIndicatorPicker(capturedType, scope) + end) + my = my - MENU_ROW_H + end + end + + AddMenuSection(L["PLACED ON FRAME"], PLACED_ITEMS, "placed") + + -- Divider + my = my - 4 + local mdiv = menuFrame:CreateTexture(nil, "ARTWORK") + mdiv:SetPoint("TOPLEFT", 8, my) + mdiv:SetPoint("RIGHT", menuFrame, "RIGHT", -8, 0) + mdiv:SetHeight(1) + mdiv:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.6) + my = my - 6 + + AddMenuSection(L["FRAME-LEVEL EFFECTS"], FRAME_ITEMS, "frame") + + menuFrame:SetHeight(-my + 6) + + addBtn:SetScript("OnClick", function() + if menuFrame:IsShown() then + menuFrame:Hide() + else + menuFrame:Show() + end + end) + + yPos = yPos - 44 + + -- ── ACTIVE INDICATORS heading ── + local activeHeader = parent:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(activeHeader, 9, "") + activeHeader:SetPoint("TOPLEFT", 8, yPos) -- align with chips/cards/add button + activeHeader:SetText(L["ACTIVE INDICATORS"]) + activeHeader:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 16 + + -- ── FILTER CHIPS (wrapping layout) ── + local chipsFrame = CreateFrame("Frame", nil, parent) + chipsFrame:SetPoint("TOPLEFT", 8, yPos) + chipsFrame:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + + local FILTER_CHIPS = { + { key = "all", label = L["All"] }, + { key = "icon", label = L["Icon"] }, + { key = "square", label = L["Square"] }, + { key = "bar", label = L["Bar"] }, + { key = "border", label = L["Border"] }, + { key = "healthbar", label = L["Health"] }, + { key = "nametext", label = L["Name"] }, + { key = "healthtext", label = L["HP"] }, + } + + local CHIP_H = 22 + local CHIP_GAP = 4 + local CHIP_ROW_GAP = 4 + local chipBtns = {} + + for _, chip in ipairs(FILTER_CHIPS) do + local chipBtn = CreateFrame("Button", nil, chipsFrame, "BackdropTemplate") + chipBtn:SetHeight(CHIP_H) + + local chipTxt = chipBtn:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(chipTxt, 10, "OUTLINE") + chipTxt:SetPoint("CENTER", 0, 0) + chipTxt:SetText(chip.label) + chipTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local tw = chipTxt:GetStringWidth() + chipBtn:SetWidth(max(tw + 16, 32)) + + -- Shared styling: standard hover + an active (selected) state marked by a + -- prominent accent border. The row rebuilds on click, so set active here. + GUI:StyleButton(chipBtn) + chipBtn:SetActive(S.activeFilter == chip.key) + + local capturedKey = chip.key + chipBtn:SetScript("OnClick", function() + S.activeFilter = capturedKey + S.SwitchTab("effects") + end) + + tinsert(chipBtns, chipBtn) + end + + -- Flow-layout: position chips with wrapping on parent resize + local function LayoutChips() + local maxW = chipsFrame:GetWidth() + if maxW < 20 then maxW = 260 end + local cx, cy = 0, 0 + for _, btn in ipairs(chipBtns) do + local bw = btn:GetWidth() + if cx > 0 and (cx + bw) > maxW then + cx = 0 + cy = cy - (CHIP_H + CHIP_ROW_GAP) + end + btn:ClearAllPoints() + btn:SetPoint("TOPLEFT", chipsFrame, "TOPLEFT", cx, cy) + cx = cx + bw + CHIP_GAP + end + chipsFrame:SetHeight(max(-cy + CHIP_H, CHIP_H)) + end + LayoutChips() + chipsFrame:SetScript("OnSizeChanged", LayoutChips) + + yPos = yPos - (chipsFrame:GetHeight() + 10) + + -- ── OTHER BUFFS HINT ── + if IsOtherTab() then + local obHint = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + obHint:SetPoint("TOPLEFT", 8, yPos) + obHint:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + obHint:SetJustifyH("LEFT") + obHint:SetWordWrap(true) + obHint:SetText(L["These indicators trigger no matter who casts the buff."]) + obHint:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) + yPos = yPos - (max(obHint:GetStringHeight(), 12) + 10) + end + + -- ── EFFECTS LIST ── + local effects = CollectAllEffects() + + -- Apply filter + local filtered = {} + for _, effect in ipairs(effects) do + if S.activeFilter == "all" or effect.typeKey == S.activeFilter then + tinsert(filtered, effect) + end + end + + if #filtered == 0 then + local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) + empty:SetWidth(220) + local spec = ResolveSpec() + local specAuras = spec and Adapter:GetTrackableAuras(spec) + -- The Other Buffs pool is spec-independent — never show the + -- unsupported-spec message there. + if not IsOtherTab() and (not spec or not specAuras or #specAuras == 0) then + empty:SetText(L["No trackable spells found for this spec.\n\nYou can select a different spec using the dropdown above."]) + elseif S.activeFilter == "all" then + empty:SetText(L["No effects configured yet.\nClick '+ Add Indicator' to get started."]) + else + empty:SetText(format(L["No %s effects configured."], (S.PLACED_TYPE_LABELS[S.activeFilter] or S.FRAME_LEVEL_LABELS[S.activeFilter] or S.activeFilter))) + end + empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) + empty:SetJustifyH("CENTER") + else + for _, effect in ipairs(filtered) do + yPos = S.CreateEffectCard(parent, yPos, effect) + end + end + + parent:SetHeight(max(-yPos + 20, 200)) +end + +-- ── BUILD GLOBAL TAB ── +-- Wraps the existing BuildGlobalView into the tab content frame +S.BuildGlobalTab = function() + if not S.tabContentFrame then return end + BuildGlobalView(S.tabContentFrame) +end + +-- ── BUILD LAYOUT GROUPS TAB ── +-- ============================================================ +-- GROUP APPEARANCE SECTION (filter-group + debuff-group cards) +-- Collapsible "Appearance" SettingsGroup (the effect-card section idiom) +-- holding the per-group icon styling the container genuinely supports: +-- cooldown swipe, border (full CreateBorderControls set incl. the DF-owned +-- animations), duration text (show / font / scale / outline / anchor / +-- offsets / colour-by-time / colour / hide-above) and stack count (show + +-- text styling). Controls bind to group.style via a defaults proxy +-- (CreateInstanceProxy's idiom): nil keys read the pre-style defaults, so +-- an untouched section changes nothing — the factory renders a style-less +-- (or all-default) group byte-identically to before. Omitted vs the placed +-- effect card, by capability: Min Stacks (no formatter on the native stack +-- path — secret trap), Hide Icon / size / scale / alpha / frame level +-- (group-level layout already owns size; the rest are per-indicator +-- concepts), Expiring / Show When Missing (remaining-time / presence reads). +-- Structural fields (show toggles, colour-by-time, hide-above, border +-- on/off) move the group struct sig -> the factory Rebuilds; everything +-- else hot-applies via the cosmetic sig. Collapse state persists PER CARD +-- under "adGroupStyle:" — cardKey is the caller's expand-key form +-- (raw id / "othergroup:" / "dgroup:"), so the three stores' keys +-- stay disjoint from each other and from the effect cards' header keys. +local function AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) + local s = group.style + if type(s) ~= "table" then s = {}; group.style = s end + + -- Defaults = today's uniform group rendering (Factory buildFilterGroupStyle's + -- pre-style values) + the icon indicator's Border* seeds so CreateBorderControls + -- reads sensible values on first open (ShowBorder overridden OFF — a group has + -- no ring until the user enables one). + local defaults = { + hideSwipe = false, showDuration = true, showStacks = true, + durationFormat = "NUMBER", + durationFont = "DF Roboto SemiBold", durationScale = 1.0, durationOutline = "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = false, durationColor = { r = 1, g = 1, b = 1, a = 1 }, + durationHideAboveEnabled = false, durationHideAboveThreshold = 10, + durationHideOnPermanent = true, -- Wave 4: absent key = ON (style-less identity) + stackFont = "DF Roboto SemiBold", stackScale = 1.0, stackOutline = "SHADOW;OUTLINE", + stackAnchor = "BOTTOMRIGHT", stackX = 2, stackY = -1, + stackColor = { r = 1, g = 1, b = 1, a = 1 }, + ShowBorder = false, + -- Duration bar strip (Wave 3) — mirrors the row pages' defaults + -- (Config.lua buffDurationBar*). OFF until the user enables it. + durationBarEnabled = false, durationBarPosition = "BOTTOM", + durationBarHeight = 4, durationBarGap = 1, durationBarColorMode = "STATIC", + durationBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", + durationBarColor = { r = 0.2, g = 0.9, b = 0.3, a = 1 }, + durationBarBGColor = { r = 0, g = 0, b = 0, a = 0.8 }, + durationBarReverseFill = false, + } + for k, v in pairs(TYPE_DEFAULTS.icon) do + if k:find("^Border") and defaults[k] == nil then defaults[k] = v end + end + + -- Defaults proxy (CreateInstanceProxy's idiom, group.style-backed): reads fall + -- through to the defaults (table fallbacks copy-on-read so colour sub-key edits + -- persist); writes land in group.style and refresh the live frames. The factory + -- reads the RAW style table with the same defaults, so UI and render agree. + local proxy = setmetatable({ _skipOverrideIndicators = true, __dfDefaults = defaults }, { + __index = function(_, k) + local val = s[k] + if val ~= nil then return val end + local fallback = defaults[k] + if type(fallback) == "table" then + local copy = {} + for fk, fv in pairs(fallback) do copy[fk] = fv end + s[k] = copy + return copy + end + return fallback + end, + __newindex = function(_, k, v) + s[k] = v + RefreshPlacedIndicators() + RefreshLiveFramesThrottled() + end, + }) + + -- Cosmetic edits hot-apply (coSig -> ApplyStyle); structural toggles move the + -- struct sig -> Rebuild. Both ride the same throttled factory re-sync. The + -- canvas placeholder's sample icons render the group style too, so every + -- appearance edit re-draws them (RefreshPlacedIndicators — the same direct + -- call the card's layout sliders run per edit/drag). + local function refresh() + RefreshPlacedIndicators() + RefreshLiveFramesThrottled() + end + -- Visibility changes inside the section (border style dropdown swapping its + -- widget set) re-measure heights, so rebuild the tab — the same full-rebuild + -- the effect cards' dropdown callbacks run (AuraDesigner_RefreshPage). + local function rebuildTab() S.SwitchTab("layout") end + + -- One collapsible box PER CATEGORY — the expanded effect card's section + -- structure (Appearance / Border / Duration Text / Stack Count, same names + -- and order as the icon card's AddGroup boxes; group-inapplicable sections + -- — Position, Show When Missing, Expiring — have no group-level analogue). + -- Collapse persists per card per section ("adGroupStyle::
"), + -- so each section toggles independently; the toggle rides the widget's + -- built-in AuraDesigner_RefreshPage rebuild like the effect cards'. + local function AddSection(header, sectionKey, buildFn) + local g = GUI:CreateSettingsGroup(body, bodyWidth - 10, { + collapsible = true, + collapseKey = "adGroupStyle:" .. tostring(cardKey) .. ":" .. sectionKey, + }) + g.padding = 10 -- match the main Options groups' inner padding (airier scale) + g:AddWidget(GUI:CreateHeader(body, header), GUI.RowHeight.sectionHeader) + buildFn(g) + local h = g:LayoutChildren() -- includes the group's own bottom margin + g:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + by = by - h + end + + -- ── APPEARANCE ── (the effect card's Appearance box; of its controls only + -- the swipe applies at group level — size/scale live in the card's layout + -- sliders, alpha/level/strata/text-only are per-indicator concepts) + AddSection(L["Appearance"], "appearance", function(g) + g:AddWidget(GUI:CreateCheckbox(body, L["Hide Cooldown Swipe"], proxy, "hideSwipe", refresh), 28) + end) + + -- ── BORDER ── (the placed icon's control set; gradient degrades to solid on + -- container slots — same known casualty as placed indicators; LCG glow types + -- are excluded from the animation dropdown, mirror the placed border) + AddSection(L["Border"], "border", function(g) + GUI:CreateBorderControls(g, proxy, "", { + parent = body, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + }, + fullUpdate = refresh, + lightUpdate = refresh, + lightColors = refresh, + refreshStates = rebuildTab, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) + end) + + -- ── DURATION TEXT ── (shared text controls; keys mirror the placed cards') + AddSection(L["Duration Text"], "duration", function(g) + g:AddWidget(GUI:CreateCheckbox(body, L["Show Duration"], proxy, "showDuration", refresh), 28) + -- Icon-sized formats only — a group renders icon rows (see the placed icon + -- card's Duration Format note). Structural: the proxy write's refresh moves + -- durationFmtKey -> the factory Rebuilds. Forward-declared + -- UpdateHideAboveState (assigned below): re-greys Hide Above, which can't + -- compose with the percent-family formats. + local UpdateHideAboveState + g:AddWidget(GUI:CreateDropdown(body, L["Duration Format"], { + NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], + _order = { "NUMBER", "SHORT", "PERCENT" }, + }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) + GUI:CreateTextControls(g, proxy, "duration", { + parent = body, + include = { color = true }, + colorLabel = L["Duration Text Color"], + colorDisableOn = function() return proxy.durationColorByTime and true or false end, + onChange = refresh, onDrag = refresh, + }) + g:AddWidget(GUI:CreateCheckbox(body, L["Color by Time Remaining"], proxy, "durationColorByTime", refresh), 28) + AddDurationColorsLink(g, body) + local hideAboveSlider, hideAboveCheck + UpdateHideAboveState = function() + if not hideAboveSlider then return end + local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) + if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end + if not pctFmt and proxy.durationHideAboveEnabled then + hideAboveSlider:SetAlpha(1) + hideAboveSlider:EnableMouse(true) + else + hideAboveSlider:SetAlpha(0.4) + hideAboveSlider:EnableMouse(false) + end + end + hideAboveCheck = GUI:CreateCheckbox(body, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", function() + UpdateHideAboveState() + refresh() + end) + g:AddWidget(hideAboveCheck, 28) + hideAboveSlider = GUI:CreateSlider(body, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold", refresh, refresh, true) + g:AddWidget(hideAboveSlider, 54) + g:AddWidget(GUI:CreateCheckbox(body, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent", refresh), 28) + UpdateHideAboveState() + end) + + -- ── STACK COUNT ── (no Min Stacks — not expressible on the native no-formatter + -- stack path, see Features/Auras.lua's stacks-formatter warning) + AddSection(L["Stack Count"], "stacks", function(g) + g:AddWidget(GUI:CreateCheckbox(body, L["Show Stacks"], proxy, "showStacks", refresh), 28) + GUI:CreateTextControls(g, proxy, "stack", { + parent = body, + include = { color = true }, + colorLabel = L["Stack Text Color"], + onChange = refresh, onDrag = refresh, + }) + end) + + -- ── DURATION BAR ── (Wave 3: strip below/above each icon, drained by the + -- native SetDurationBar fill — render-side, works on secret auras. The keys + -- mirror the row pages' buffDurationBar* block; enable/position/height/gap + -- are structural (group struct sig -> Rebuild), texture/colours hot-apply.) + AddSection(L["Duration Bar"], "durationbar", function(g) + -- Greys IMPERATIVELY, not via widget.disableOn: this is an AD editor card, which + -- has no disableOn/RefreshStates loop (that seam only runs on the SettingsGroup + -- row pages). The enable gate AND the curve-mode dimming of Texture/Bar Color are + -- driven by hand from the Enable + Color Mode callbacks. curveGated flags the two + -- controls a curve mode overrides. + local dbWidgets, curveGated = {}, {} + local function UpdateBarGrey() + local on = proxy.durationBarEnabled and true or false + local curve = DF:IsDurationBarCurveMode(proxy.durationBarColorMode) + for i = 1, #dbWidgets do + local w = dbWidgets[i] + local enable = on and not (curveGated[w] and curve) + if w.SetEnabled then w:SetEnabled(enable) + else + w:SetAlpha(enable and 1 or 0.4) + if w.EnableMouse then w:EnableMouse(enable) end + end + end + end + g:AddWidget(GUI:CreateCheckbox(body, L["Enable Duration Bar"], proxy, "durationBarEnabled", function() + UpdateBarGrey() + refresh() + end), 28) + local function barChild(widget, h) + g:AddWidget(widget, h) + dbWidgets[#dbWidgets + 1] = widget + return widget + end + barChild(GUI:CreateDropdown(body, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, proxy, "durationBarPosition", refresh), 54) + barChild(GUI:CreateSlider(body, L["Height"], 1, 12, 1, proxy, "durationBarHeight", refresh, refresh, true), 54) + barChild(GUI:CreateSlider(body, L["Gap"], 0, 10, 1, proxy, "durationBarGap", refresh, refresh, true), 54) + barChild(GUI:CreateDropdown(body, L["Color Mode"], + DF:GetDurationBarColorModes(), + proxy, "durationBarColorMode", function() UpdateBarGrey(); refresh() end), 54) + -- A curve mode brings its own ramp texture and forces a white tint, so these two + -- do nothing while it is selected - dim them (curveGated) rather than leave dead + -- controls live. + local adBarTex = barChild(GUI:CreateTextureDropdown(body, L["Bar Texture"], proxy, "durationBarTexture", refresh), 54) + local adBarCol = barChild(GUI:CreateColorPicker(body, L["Bar Color"], proxy, "durationBarColor", true, refresh, refresh, true), 28) + curveGated[adBarTex] = true; curveGated[adBarCol] = true + barChild(GUI:CreateColorPicker(body, L["Background Color"], proxy, "durationBarBGColor", true, refresh, refresh, true), 28) + barChild(GUI:CreateCheckbox(body, L["Reverse Fill"], proxy, "durationBarReverseFill", refresh), 28) + UpdateBarGrey() + end) + + return by +end \ No newline at end of file diff --git a/AuraDesigner/UI/Editor.lua b/AuraDesigner/UI/Editor.lua new file mode 100644 index 00000000..f56d798d --- /dev/null +++ b/AuraDesigner/UI/Editor.lua @@ -0,0 +1,1915 @@ +-- Part 5 of the Aura Designer editor, split from Options.lua. +-- Aliases of objects the first part created; they add no state. +local addonName, DF = ... +local L = DF.L +local GUI = DF.GUI +local Adapter = DF.AuraDesigner.Adapter +local S = DF.AuraDesigner._uiState +local P = DF.AuraDesigner._priv +local C_BACKGROUND = GUI.Colors.background +local C_ELEMENT = GUI.Colors.element +local C_BORDER = GUI.Colors.border +local C_HOVER = GUI.Colors.hover +local C_TEXT = GUI.Colors.text +local C_TEXT_DIM = GUI.Colors.textDim +local OPTS = P.OPTS +local GetAuraDesignerDB = P.GetAuraDesignerDB +local GetThemeColor = P.GetThemeColor +local ApplyBackdrop = P.ApplyBackdrop +local CreateCardShell = P.CreateCardShell +local ResolveSpec = P.ResolveSpec +local CreateDebuffGroup = P.CreateDebuffGroup +local IsOtherTab = P.IsOtherTab +local CurrentAuraPool = P.CurrentAuraPool +local PoolKeyPrefix = P.PoolKeyPrefix +local DebuffGroupsRead = P.DebuffGroupsRead +local CurrentLayoutGroups = P.CurrentLayoutGroups +local OtherPoolDisplayName = P.OtherPoolDisplayName +local RemoveIndicatorInstance = P.RemoveIndicatorInstance +local GetAuraIcon = P.GetAuraIcon +local expandedGroups = P.expandedGroups +local GroupExpandKey = P.GroupExpandKey +local CreateLayoutGroup = P.CreateLayoutGroup +local DeleteLayoutGroup = P.DeleteLayoutGroup +local DeleteDebuffGroup = P.DeleteDebuffGroup +local RemoveGroupMember = P.RemoveGroupMember +local SwapGroupMembers = P.SwapGroupMembers +local expandedCards = P.expandedCards +local tabButtons = P.tabButtons +local mainTabButtons = P.mainTabButtons +local effectCardPool = P.effectCardPool +local BADGE_COLORS = P.BADGE_COLORS +local placedIndicators = P.placedIndicators +local ClearPlacedIndicators = P.ClearPlacedIndicators +local RefreshPlacedIndicators = P.RefreshPlacedIndicators +local RefreshPreviewEffects = P.RefreshPreviewEffects +local CreateEnableBanner = P.CreateEnableBanner +local CreateSpecDropdown = P.CreateSpecDropdown +local CreateFramePreview = P.CreateFramePreview +local UpdateLayoutTabState = P.UpdateLayoutTabState +local UpdateSpecDropdownState = P.UpdateSpecDropdownState +local SetMainTab = P.SetMainTab +local OpenGroupSpellPicker = P.OpenGroupSpellPicker +local AddGroupAppearanceSection = P.AddGroupAppearanceSection +P.AddGroupAppearanceSection = AddGroupAppearanceSection + +S.BuildLayoutGroupsTab = function() + if not S.tabContentFrame then return end + local parent = S.tabContentFrame + local yPos = -10 + local tc = GetThemeColor() + + -- (Removed) a GROW_DIRECTIONS option map — never read. Also note its labels were + -- raw English, so wiring it up as-is would have been a localisation regression. + + -- "+ Create Group" / "+ Filter Group" buttons (prominent, theme-colored). + -- Left = classic member-arranger group; right = registry-linked filter group. + local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab color + local addBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + addBtn:SetHeight(32) + addBtn:SetPoint("TOPLEFT", 8, yPos) + addBtn:SetPoint("TOPRIGHT", parent, "TOP", -2, yPos) + GUI:StyleButton(addBtn, { height = 32, primary = true, accent = gc, text = L["+ Create Group"], font = "DFFontHighlight" }) + addBtn:SetScript("OnClick", function() + local group = CreateLayoutGroup() + if group then + expandedGroups[GroupExpandKey(group.id)] = true + S.SwitchTab("layout") + RefreshPlacedIndicators() + end + end) + + local addFilterBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + addFilterBtn:SetHeight(32) + addFilterBtn:SetPoint("TOPLEFT", parent, "TOP", 2, yPos) + addFilterBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, yPos) + GUI:StyleButton(addFilterBtn, { height = 32, primary = true, accent = gc, text = L["+ Filter Group"], font = "DFFontHighlight" }) + addFilterBtn:SetScript("OnClick", function() + local group = CreateLayoutGroup(nil, "filter") + if group then + expandedGroups[GroupExpandKey(group.id)] = true + S.SwitchTab("layout") + RefreshPlacedIndicators() + end + end) + yPos = yPos - 42 + + -- ── LAYOUT GROUPS heading — mirrors the Effects tab's ACTIVE INDICATORS + -- caption and the Text Designer's group caption so every list tab has one. ── + local groupsHeader = parent:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(groupsHeader, 9, "") + groupsHeader:SetPoint("TOPLEFT", 8, yPos) + groupsHeader:SetText(L["LAYOUT GROUPS"]) + groupsHeader:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 16 + + -- Active tab's groups: the spec-keyed array on My Buffs, the flat + -- spec-independent store on Other Buffs (read-only — visiting the tab + -- never creates adDB.otherLayoutGroups; the add buttons do). + local isOtherGroups = IsOtherTab() + local groups = CurrentLayoutGroups() + + -- Display name lookup (My Buffs: the spec's trackable pool; Other Buffs: + -- ad-hoc/SpellDB resolution — other-pool names aren't in a spec pool) + local spec = ResolveSpec() + local trackable = not isOtherGroups and spec and Adapter and Adapter:GetTrackableAuras(spec) + local displayNames = {} + if trackable then + for _, info in ipairs(trackable) do + displayNames[info.name] = info.display + end + end + local function MemberDisplayName(auraName) + if isOtherGroups then return OtherPoolDisplayName(auraName) end + return displayNames[auraName] or auraName + end + + if #groups == 0 then + -- Empty state + local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) + empty:SetWidth(220) + empty:SetText(L["No layout groups created yet.\nClick '+ Create Group' to get started."]) + empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) + empty:SetJustifyH("CENTER") + else + -- Render group cards (expansion keys are pool-scoped — raw id on My + -- Buffs, "othergroup:" on Other; the id counters overlap) + for _, group in ipairs(groups) do + local expandKey = GroupExpandKey(group.id) + local isExpanded = expandedGroups[expandKey] or false + + -- ── CARD + HEADER ── + local card, header, chevron = CreateCardShell(parent, { + yPos = yPos, + expanded = isExpanded, + borderColor = {r = gc.r * 0.35, g = gc.g * 0.35, b = gc.b * 0.35, a = 0.5}, + chevronColor = gc, + }) + + -- Group name + local nameText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameText:SetPoint("LEFT", chevron, "RIGHT", 6, 0) + nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) + nameText:SetMaxLines(1) + local isFilterGroup = (group.kind == "filter") + if isFilterGroup then + local linkCount = 0 + local fsel = group.filterSelection + if fsel then + for _ in pairs(fsel.presets or {}) do linkCount = linkCount + 1 end + for _ in pairs(fsel.customs or {}) do linkCount = linkCount + 1 end + end + local fgInfo = group.name .. " - " .. linkCount .. (linkCount ~= 1 and L[" filters"] or L[" filter"]) + -- Collapsed-state Others Only suffix — mirror the effect-card header + if isOtherGroups and group.othersOnly then + fgInfo = fgInfo .. " - " .. L["Others Only"] + end + nameText:SetText(fgInfo) + else + local memberCount = group.members and #group.members or 0 + nameText:SetText(group.name .. " - " .. memberCount .. (memberCount ~= 1 and L[" indicators"] or L[" indicator"])) + end + nameText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Delete button + local capturedGroupID = group.id + local delBtn = GUI:CreateCloseButton(header, { + size = 22, + onClick = function() + DeleteLayoutGroup(capturedGroupID) + S.SwitchTab("layout") + RefreshPlacedIndicators() + -- Deleting a group deletes its member indicators — same + -- structural refresh as the effect-card delete / eye toggle. + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end, + }) + delBtn:SetPoint("RIGHT", -4, 0) + delBtn:SetFrameLevel(header:GetFrameLevel() + 2) + + -- Eye icon (visibility toggle) — filter groups only; same asset + toggle + -- idiom as the effect-card eye (A3). enabled == false is hidden; nil/true + -- = shown. Toggling is STRUCTURAL: the factory tears down / stands up the + -- group container and the buff-row dedup union changes. + if isFilterGroup then + local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) + eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) + local function shown() return group.enabled ~= false end + -- SetGlyph makes the state colour the new REST colour, so OnLeave + -- restores the state; hover is suppressed while hidden. + local function updateEyeIcon() + if shown() then + eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) + else + eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) + end + eyeBtn:SetGlyphHover(shown()) + end + updateEyeIcon() + eyeBtn:RegisterForClicks("LeftButtonUp") + eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) + eyeBtn:SetScript("OnClick", function() + group.enabled = (group.enabled == false) and true or false + updateEyeIcon() + S.SwitchTab("layout") + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end) + if not shown() then + nameText:SetAlpha(0.5) + end + end + + -- Header click → toggle expansion + header:SetScript("OnClick", function() + expandedGroups[expandKey] = not expandedGroups[expandKey] + S.SwitchTab("layout") + end) + header:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + header:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + local totalCardH = 30 + + -- ── BODY (when expanded) ── + if isExpanded then + local body = CreateFrame("Frame", nil, card, "BackdropTemplate") + body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) + body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) + ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, + {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) + + local by = -10 + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 + if bodyWidth < 100 then bodyWidth = 240 end + + -- Group Name (editable) + local nameLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(nameLabel, 8, "") + nameLabel:SetPoint("TOPLEFT", 8, by) + nameLabel:SetText(L["GROUP NAME"]) + nameLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 16 + + local nameEdit = CreateFrame("EditBox", nil, body, "BackdropTemplate") + nameEdit:SetHeight(22) + nameEdit:SetPoint("TOPLEFT", 8, by) + nameEdit:SetPoint("RIGHT", body, "RIGHT", -8, 0) + nameEdit:SetAutoFocus(false) + nameEdit:SetText(group.name) + nameEdit:SetMaxLetters(30) + GUI:StyleEditBox(nameEdit, {}) + nameEdit:SetScript("OnEnterPressed", function(self) + local val = self:GetText() + if val and val ~= "" then + group.name = val + end + self:ClearFocus() + S.SwitchTab("layout") + end) + nameEdit:SetScript("OnEscapePressed", function(self) + self:SetText(group.name) + self:ClearFocus() + end) + by = by - 32 + + if isFilterGroup then + -- ── LINKED FILTERS SECTION (filter groups) ── + -- One collapsed chip per linked registry filter: localized preset name + -- (or custom filter name) + live spell count, remove ✕. Links are stable + -- REFERENCES (preset keys / custom ids) — never copies — so filter edits + -- propagate live. Link/unlink is structural (container rebuild + the + -- buff-row dedup union moves), so run the full refresh path. + local R = DF.FilterRegistry + local fsel = group.filterSelection + if not fsel then fsel = {}; group.filterSelection = fsel end + fsel.presets = fsel.presets or {} + fsel.customs = fsel.customs or {} + + local function StructuralFilterRefresh() + S.SwitchTab("layout") + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end + + local lfLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(lfLabel, 8, "") + lfLabel:SetPoint("TOPLEFT", 8, by) + lfLabel:SetText(L["LINKED FILTERS"]) + lfLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + -- Custom-filter spell count (curated spells + raw IDs) + local function CustomFilterCount(cf) + local n = 0 + if cf then + for _ in pairs(cf.spells or {}) do n = n + 1 end + for _ in pairs(cf.rawIDs or {}) do n = n + 1 end + end + return n + end + + -- Linked list: presets in R.Categories order, then customs name-sorted. + local linked = {} + for _, cat in ipairs(R.Categories) do + if fsel.presets[cat.key] then + local enabled, total = R:PresetCounts(cat.key) + tinsert(linked, { kind = "preset", key = cat.key, + label = format("%s |cff888888(%d/%d)|r", L[cat.name], enabled, total) }) + end + end + local linkedCustoms = {} + for cfId in pairs(fsel.customs) do tinsert(linkedCustoms, cfId) end + sort(linkedCustoms, function(a, b) + local fa, fb = R:GetCustomFilter(a), R:GetCustomFilter(b) + local na, nb = (fa and fa.name or ""), (fb and fb.name or "") + if na ~= nb then return na < nb end + return a < b + end) + for _, cfId in ipairs(linkedCustoms) do + local cf = R:GetCustomFilter(cfId) + tinsert(linked, { kind = "custom", key = cfId, + label = format("%s |cff888888(%d)|r", (cf and cf.name) or cfId, CustomFilterCount(cf)) }) + end + + if #linked > 0 then + for _, link in ipairs(linked) do + local chipRow = CreateFrame("Frame", nil, body, "BackdropTemplate") + chipRow:SetHeight(24) + chipRow:SetPoint("TOPLEFT", 8, by) + chipRow:SetPoint("RIGHT", body, "RIGHT", -8, 0) + ApplyBackdrop(chipRow, + {r = 0.11, g = 0.11, b = 0.11, a = 1}, + {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3}) + + -- Remove ✕ (mirror the member-row remove idiom) + local remBtn = DF.GUI:CreateGlyphButton(chipRow, { + size = 18, iconSize = 12, + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", + color = { 0.55, 0.30, 0.30 }, + hoverColor = { 1, 0.40, 0.40 }, + }) + remBtn:SetPoint("RIGHT", -4, 0) + local capturedLink = link + remBtn:SetScript("OnClick", function() + if capturedLink.kind == "preset" then + fsel.presets[capturedLink.key] = nil + else + fsel.customs[capturedLink.key] = nil + end + StructuralFilterRefresh() + end) + + local chipText = chipRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + chipText:SetPoint("LEFT", 8, 0) + chipText:SetPoint("RIGHT", remBtn, "LEFT", -4, 0) + chipText:SetMaxLines(1) + chipText:SetJustifyH("LEFT") + chipText:SetText(link.label) + chipText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + by = by - 28 + end + else + local noLinks = body:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + noLinks:SetPoint("TOPLEFT", 12, by) + noLinks:SetText(L["No filters linked yet"]) + noLinks:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + by = by - 20 + end + + -- "+ Add Filter" button → mini-picker of unlinked presets + customs + by = by - 6 + local addFilterLinkBtn = CreateFrame("Button", nil, body, "BackdropTemplate") + addFilterLinkBtn:SetHeight(22) + addFilterLinkBtn:SetPoint("TOPLEFT", 8, by) + addFilterLinkBtn:SetPoint("RIGHT", body, "RIGHT", -8, 0) + GUI:StyleButton(addFilterLinkBtn, { height = 22, primary = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 11 }, text = L["Add Filter"] }) + GUI:SetSettingsFont(addFilterLinkBtn.Text, 9, "") + addFilterLinkBtn:SetScript("OnClick", function() + -- Build the unlinked candidate list: presets (Categories order, + -- live counts), then customs (name-sorted). No Uncategorised + -- option by design — a group must resolve to an include map. + local candidates = {} + for _, cat in ipairs(R.Categories) do + if not fsel.presets[cat.key] then + local enabled, total = R:PresetCounts(cat.key) + tinsert(candidates, { kind = "preset", key = cat.key, + label = format("%s |cff888888(%d/%d)|r", L[cat.name], enabled, total) }) + end + end + local freeCustoms = {} + for cfId in pairs(R:GetStore().customFilters) do + if not fsel.customs[cfId] then tinsert(freeCustoms, cfId) end + end + sort(freeCustoms, function(a, b) + local fa, fb = R:GetCustomFilter(a), R:GetCustomFilter(b) + local na, nb = (fa and fa.name or ""), (fb and fb.name or "") + if na ~= nb then return na < nb end + return a < b + end) + for _, cfId in ipairs(freeCustoms) do + local cf = R:GetCustomFilter(cfId) + tinsert(candidates, { kind = "custom", key = cfId, + label = format("%s |c%s(%s)|r", (cf and cf.name) or cfId, GUI:ToneHex("info"), L["Custom"]) }) + end + + -- Create/reuse the picker dropdown (same overlay/ESC/scroll idiom + -- as the member picker above) + local dropName = "DFADFilterGroupPicker" + local drop = _G[dropName] + if not drop then + drop = CreateFrame("Frame", dropName, UIParent, "BackdropTemplate") + drop:SetFrameStrata("FULLSCREEN_DIALOG") + drop:SetClampedToScreen(true) + local overlay = CreateFrame("Button", nil, UIParent) + overlay:SetAllPoints(UIParent) + overlay:SetFrameStrata("FULLSCREEN") + overlay:Hide() + overlay:SetScript("OnClick", function() + drop:Hide() + overlay:Hide() + end) + drop._overlay = overlay + -- SetPropagateKeyboardInput is protected in combat for + -- insecure code: skip the calls there (keys propagate + -- anyway — ESC just hides the dropdown), and don't trap + -- keyboard input if built mid-combat. + if not InCombatLockdown() then + drop:EnableKeyboard(true) + drop:SetPropagateKeyboardInput(true) + end + drop:SetScript("OnKeyDown", function(self, key) + if key == "ESCAPE" then + if not InCombatLockdown() then self:SetPropagateKeyboardInput(false) end + self:Hide() + else + if not InCombatLockdown() then self:SetPropagateKeyboardInput(true) end + end + end) + drop:SetScript("OnHide", function(self) + self._ownerBtn = nil + if self._overlay then self._overlay:Hide() end + end) + end + if drop:IsShown() and drop._ownerBtn == addFilterLinkBtn then + drop:Hide() + return + end + drop._ownerBtn = addFilterLinkBtn + + local DROP_W = 240 + local MAX_H = 300 + drop:SetWidth(DROP_W) + ApplyBackdrop(drop, C_BACKGROUND, C_BORDER) + + if not drop._scrollFrame then + local sf = CreateFrame("ScrollFrame", nil, drop) + sf:SetPoint("TOPLEFT", 0, 0) + sf:SetPoint("BOTTOMRIGHT", 0, 0) + drop._scrollFrame = sf + local sc = CreateFrame("Frame", nil, sf) + sc:SetWidth(DROP_W) + sf:SetScrollChild(sc) + drop._scrollChild = sc + sf:SetScript("OnMouseWheel", function(self2, delta2) + local cur = self2:GetVerticalScroll() + local maxS = max(0, self2:GetVerticalScrollRange()) + self2:SetVerticalScroll(max(0, min(maxS, cur - (delta2 * 24)))) + end) + end + local scrollChild = drop._scrollChild + local scrollFrame = drop._scrollFrame + scrollChild:SetWidth(DROP_W) + for _, child in ipairs({scrollChild:GetChildren()}) do child:Hide(); child:SetParent(nil) end + for _, rgn in ipairs({scrollChild:GetRegions()}) do + if rgn:GetObjectType() == "FontString" or rgn:GetObjectType() == "Texture" then rgn:Hide() end + end + scrollFrame:Show() + scrollChild:EnableMouseWheel(true) + scrollChild:SetScript("OnMouseWheel", function(_, delta2) + scrollFrame:GetScript("OnMouseWheel")(scrollFrame, delta2) + end) + + local dy2 = -4 + if #candidates == 0 then + local none = scrollChild:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(none, 9, "") + none:SetPoint("TOPLEFT", 8, dy2 - 4) + none:SetText(L["No filters available"]) + none:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) + dy2 = dy2 - 24 + end + for _, cand in ipairs(candidates) do + local ROW_H = 24 + local row = CreateFrame("Button", nil, scrollChild) + row:SetHeight(ROW_H) + row:SetPoint("TOPLEFT", 4, dy2) + row:SetPoint("RIGHT", scrollChild, "RIGHT", -4, 0) + + local rName = row:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(rName, 9, "") + rName:SetPoint("LEFT", 8, 0) + rName:SetText(cand.label) + rName:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local hl = row:CreateTexture(nil, "BACKGROUND") + hl:SetAllPoints() + hl:SetColorTexture(1, 1, 1, 0) + row:SetScript("OnEnter", function() hl:SetColorTexture(1, 1, 1, 0.03) end) + row:SetScript("OnLeave", function() hl:SetColorTexture(1, 1, 1, 0) end) + + local capturedCand = cand + row:SetScript("OnClick", function() + if capturedCand.kind == "preset" then + fsel.presets[capturedCand.key] = true + else + fsel.customs[capturedCand.key] = true + end + drop:Hide() + StructuralFilterRefresh() + end) + dy2 = dy2 - ROW_H + end + local totalH = -dy2 + 4 + scrollChild:SetHeight(totalH) + drop:SetHeight(math.min(totalH, MAX_H)) + + drop:ClearAllPoints() + drop:SetPoint("TOPLEFT", addFilterLinkBtn, "BOTTOMLEFT", 0, -2) + drop:Show() + if drop._overlay then drop._overlay:Show() end + end) + by = by - 26 + + -- "Create Filter" → jump to the Filter Designer and pulse its + -- New Filter button, for users who arrive here without a custom + -- filter to link yet. + local createFilterBtn = CreateFrame("Button", nil, body, "BackdropTemplate") + createFilterBtn:SetHeight(22) + createFilterBtn:SetPoint("TOPLEFT", 8, by) + createFilterBtn:SetPoint("RIGHT", body, "RIGHT", -8, 0) + GUI:StyleButton(createFilterBtn, { height = 22, text = L["Create Filter"] }) + GUI:SetSettingsFont(createFilterBtn.Text, 9, "") + createFilterBtn:SetScript("OnClick", function() + if GUI.SelectTab and GUI.Pages and GUI.Pages["auras_filterdesigner"] then + GUI.SelectTab("auras_filterdesigner") + -- Page content builds on first show (inside SelectTab), so + -- the button reference exists by now. + -- The add action is a row inside the Filter Designer's + -- scrolling left list, so the S.page scrolls it into view and + -- pulses it itself rather than handing back a bare widget. + local fdPage = GUI.Pages["auras_filterdesigner"] + if fdPage and fdPage._fdFocusNewFilter then + fdPage._fdFocusNewFilter() + end + end + end) + by = by - 28 + + else + -- ── MEMBERS SECTION ── + local memLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(memLabel, 8, "") + memLabel:SetPoint("TOPLEFT", 8, by) + memLabel:SetText(L["MEMBERS"]) + memLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + if group.members and #group.members > 0 then + for mi, member in ipairs(group.members) do + local memberRow = CreateFrame("Frame", nil, body, "BackdropTemplate") + memberRow:SetHeight(34) + memberRow:SetPoint("TOPLEFT", 8, by) + memberRow:SetPoint("RIGHT", body, "RIGHT", -8, 0) + ApplyBackdrop(memberRow, + {r = 0.11, g = 0.11, b = 0.11, a = 1}, + {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3}) + + -- Up/Down buttons for reordering (stacked vertically on left) + local canMoveUp = mi > 1 + local canMoveDown = mi < #group.members + local capturedMi = mi + + if canMoveUp then + -- One arrow texture serves both directions via rotation. + local upBtn = DF.GUI:CreateGlyphButton(memberRow, { + width = 20, height = 16, iconSize = 14, + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more", + rotation = math.rad(180), + }) + upBtn:SetPoint("TOPLEFT", 2, -1) + upBtn:SetScript("OnClick", function() + SwapGroupMembers(capturedGroupID, capturedMi, capturedMi - 1) + S.SwitchTab("layout") + RefreshPlacedIndicators() + -- Positions moved (member index feeds the grid) — re-arrange live frames. + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + end + if canMoveDown then + local downBtn = DF.GUI:CreateGlyphButton(memberRow, { + width = 20, height = 16, iconSize = 14, + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more", + }) + downBtn:SetPoint("BOTTOMLEFT", 2, 1) + downBtn:SetScript("OnClick", function() + SwapGroupMembers(capturedGroupID, capturedMi, capturedMi + 1) + S.SwitchTab("layout") + RefreshPlacedIndicators() + -- Positions moved (member index feeds the grid) — re-arrange live frames. + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + end + + -- Spell icon (Other: nil spec — GetAuraIcon degrades to + -- ad-hoc "#" / SpellDB-by-name resolution) + local memberSpec = not isOtherGroups and ResolveSpec() or nil + local memberIconTex = GetAuraIcon(memberSpec, member.auraName) + local mSpellIcon = memberRow:CreateTexture(nil, "ARTWORK") + mSpellIcon:SetSize(22, 22) + mSpellIcon:SetPoint("LEFT", 26, 0) + if memberIconTex then + mSpellIcon:SetTexture(memberIconTex) + mSpellIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + else + -- Color swatch fallback + local auraInfo2 = nil + local trackable2 = memberSpec and Adapter and Adapter:GetTrackableAuras(memberSpec) + if trackable2 then + for _, ai in ipairs(trackable2) do + if ai.name == member.auraName then auraInfo2 = ai; break end + end + end + if auraInfo2 then + mSpellIcon:SetColorTexture(auraInfo2.color[1] * 0.5, auraInfo2.color[2] * 0.5, auraInfo2.color[3] * 0.5, 1) + else + mSpellIcon:SetColorTexture(0.25, 0.25, 0.25, 1) + end + end + + -- Type badge (members live in the group's pool = the + -- active tab's pool) + local memberType = nil + local memberAuraCfg = CurrentAuraPool()[member.auraName] + if memberAuraCfg and memberAuraCfg.indicators then + for _, ind in ipairs(memberAuraCfg.indicators) do + if ind.id == member.indicatorID then + memberType = ind.type + break + end + end + end + local mBadgeColor = BADGE_COLORS[memberType or "icon"] or BADGE_COLORS.icon + local mBadgeLabel = S.PLACED_TYPE_LABELS[memberType or "icon"] or "Icon" + + local mBadge = CreateFrame("Frame", nil, memberRow, "BackdropTemplate") + mBadge:SetHeight(16) + mBadge:SetPoint("LEFT", mSpellIcon, "RIGHT", 4, 0) + ApplyBackdrop(mBadge, + {r = mBadgeColor.r * 0.20, g = mBadgeColor.g * 0.20, b = mBadgeColor.b * 0.20, a = 1}, + {r = mBadgeColor.r * 0.45, g = mBadgeColor.g * 0.45, b = mBadgeColor.b * 0.45, a = 0.6}) + local mBadgeText = mBadge:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(mBadgeText, 8, "OUTLINE") + mBadgeText:SetPoint("CENTER", 0, 0) + mBadgeText:SetText(mBadgeLabel) + mBadgeText:SetTextColor(1, 1, 1) + mBadge:SetWidth(max(mBadgeText:GetStringWidth() + 12, 32)) + + -- Remove button (using close icon) + -- Red at rest, brighter red on hover: an inline destructive remove. + local remBtn = DF.GUI:CreateGlyphButton(memberRow, { + size = 18, iconSize = 12, + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", + color = { 0.55, 0.30, 0.30 }, + hoverColor = { 1, 0.40, 0.40 }, + }) + remBtn:SetPoint("RIGHT", -4, 0) + local capturedMember = member + remBtn:SetScript("OnClick", function() + RemoveGroupMember(capturedGroupID, capturedMember.auraName, capturedMember.indicatorID) + -- Also delete the placed indicator itself + RemoveIndicatorInstance(capturedMember.auraName, capturedMember.indicatorID) + S.SwitchTab("layout") + RefreshPlacedIndicators() + RefreshPreviewEffects() + -- Structural change: same full refresh as the + -- effect-card ✕ / group delete (indicator removed). + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end) + + -- Customise button (navigates to Effects tab for this indicator) + -- Accent-tinted action button: persistent accent fill + + -- accent border + accent label at rest, accent-wash hover. + local custBtn = CreateFrame("Button", nil, memberRow, "BackdropTemplate") + custBtn:SetPoint("RIGHT", remBtn, "LEFT", -4, 0) + GUI:StyleButton(custBtn, { + width = 56, height = 18, + text = L["Customise"], + tinted = true, + accent = GetThemeColor(), + }) + local capturedAuraName = member.auraName + local capturedIndID = member.indicatorID + custBtn:SetScript("OnClick", function() + -- Card keys embed the B1 pool prefix in the name + -- segment ("placed:other:#" on Other) + local cardKey = "placed:" .. PoolKeyPrefix() .. capturedAuraName .. "#" .. capturedIndID + wipe(expandedCards) + expandedCards[cardKey] = true + S.activeTab = "effects" + DF:AuraDesigner_RefreshPage() + end) + + -- Aura name + local mName = memberRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + mName:SetPoint("LEFT", mBadge, "RIGHT", 6, 0) + mName:SetPoint("RIGHT", custBtn, "LEFT", -4, 0) + mName:SetMaxLines(1) + mName:SetText(MemberDisplayName(member.auraName)) + mName:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + by = by - 38 + end + else + local noMem = body:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + noMem:SetPoint("TOPLEFT", 12, by) + noMem:SetText(L["No members yet"]) + noMem:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + by = by - 20 + end + + -- "+ Add aura" button + by = by - 6 + local addMemBtn = CreateFrame("Button", nil, body, "BackdropTemplate") + addMemBtn:SetHeight(22) + addMemBtn:SetPoint("TOPLEFT", 8, by) + addMemBtn:SetPoint("RIGHT", body, "RIGHT", -8, 0) + GUI:StyleButton(addMemBtn, { height = 22, primary = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 11 }, text = L["Add aura"] }) + GUI:SetSettingsFont(addMemBtn.Text, 9, "") + addMemBtn:SetScript("OnClick", function() + -- Shared spell picker, group context: searchable, + -- class/category-filterable rows + add-by-ID, each row + -- carrying Icon/Square buttons that create the indicator + -- and enrol it in this group in one click. + OpenGroupSpellPicker(capturedGroupID) + end) + by = by - 28 + end -- isFilterGroup / members branch + + -- ── PLACEMENT SECTION ── + by = by - 10 + local placeLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(placeLabel, 8, "") + placeLabel:SetPoint("TOPLEFT", 8, by) + placeLabel:SetText(L["PLACEMENT"]) + placeLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + -- Use GUI widgets with the group table as the proxy + local anchorDrop = GUI:CreateDropdown(body, L["Anchor"], OPTS.ANCHOR_OPTIONS, group, "anchor", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + anchorDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if anchorDrop.SetWidth then anchorDrop:SetWidth(bodyWidth - 10) end + by = by - 54 + + local oxSlider = GUI:CreateSlider(body, L["Offset X"], -150, 150, 1, group, "offsetX", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end, function() + RefreshPlacedIndicators() + end) + oxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if oxSlider.SetWidth then oxSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + local oySlider = GUI:CreateSlider(body, L["Offset Y"], -150, 150, 1, group, "offsetY", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end, function() + RefreshPlacedIndicators() + end) + oySlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if oySlider.SetWidth then oySlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- ── GROWTH SECTION ── + by = by - 10 + local growLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(growLabel, 8, "") + growLabel:SetPoint("TOPLEFT", 8, by) + growLabel:SetText(L["GROWTH"]) + growLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + -- Auto-migrate legacy single-direction values to new format + local gd = group.growDirection or "RIGHT" + if not gd:find("_") then + local LEGACY_MAP = { RIGHT = "RIGHT_DOWN", LEFT = "LEFT_DOWN", UP = "UP_RIGHT", DOWN = "DOWN_RIGHT" } + group.growDirection = LEGACY_MAP[gd] or "RIGHT_DOWN" + end + + local growthControl = GUI:CreateGrowthControl(body, group, "growDirection", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + growthControl:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if growthControl.SetWidth then growthControl:SetWidth(bodyWidth - 10) end + by = by - 158 + + local iprSlider = GUI:CreateSlider(body, L["Icons Per Row"], 1, 20, 1, group, "iconsPerRow", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end, function() + RefreshPlacedIndicators() + end) + iprSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if iprSlider.SetWidth then iprSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + local spacingSlider = GUI:CreateSlider(body, L["Spacing"], -5, 20, 1, group, "spacing", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end, function() + RefreshPlacedIndicators() + end) + spacingSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if spacingSlider.SetWidth then spacingSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + if isFilterGroup then + -- Uniform per-group styling: one icon size + slot cap for every + -- spell the linked filters match (no per-spell styling by design). + local sizeSlider = GUI:CreateSlider(body, L["Icon Size"], 8, 64, 1, group, "iconSize", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end, function() + RefreshPlacedIndicators() + end) + sizeSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if sizeSlider.SetWidth then sizeSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- Max Icons is STRUCTURAL (slot count is declared at container + -- build) — the factory Rebuild path handles it (OOC-deferred). + local maxSlider = GUI:CreateSlider(body, L["Max Icons"], 1, 20, 1, group, "maxIcons", function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end, function() + RefreshPlacedIndicators() + end) + maxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if maxSlider.SetWidth then maxSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- ── SORT (Wave 2) ── per-group sort is TUNING: the factory + -- re-applies it in place via ApplyTuning (OOC-immediate; + -- self-defers in combat) — no rebuild. The fields are + -- OPTIONAL on the group (othersOnly idiom): absent = the + -- family default "DEFAULT" (Blizzard slot order, the + -- pre-Wave-2 behaviour), read through a customGet so legacy + -- groups display correctly without a write-on-open. + local sortRefresh = function() + RefreshPlacedIndicators() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + local sortMineCb -- forward capture: the dropdown greys it + local sortDrop = GUI:CreateDropdown(body, L["Sort Order"], OPTS.SORT_OPTIONS, nil, nil, function() + sortRefresh() + if sortMineCb then sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "DEFAULT")) end + end, function() return group.sortOrder or "DEFAULT" end, + function(v) group.sortOrder = v end) + sortDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) + if sortDrop.SetWidth then sortDrop:SetWidth(bodyWidth - 10) end + by = by - 54 + + sortMineCb = GUI:CreateCheckbox(body, L["My Auras First"], group, "sortMineFirst", sortRefresh) + sortMineCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(-by)) + sortMineCb:SetWidth(bodyWidth - 16) + sortMineCb.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] + sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "DEFAULT")) + by = by - 26 + + local sortRevCb = GUI:CreateCheckbox(body, L["Reverse Order"], group, "sortReverse", sortRefresh) + sortRevCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(-by)) + sortRevCb:SetWidth(bodyWidth - 16) + sortRevCb.tooltip = L["Reverse the sort direction."] + by = by - 30 + + -- ── OTHERS ONLY (Other Buffs tab only — flat-store groups) ── + -- Same idiom as the effect-card checkbox: the group's filter + -- string ("HELPFUL|!PLAYER") binds at container build, so + -- toggling is STRUCTURAL (folded into the fgroup struct sig → + -- the factory Rebuilds), and the buff-row dedup union moves + -- (an othersOnly group's spells keep their row icon). + if isOtherGroups then + local ooCb = GUI:CreateCheckbox(body, L["Others Only"], group, "othersOnly", function() + S.SwitchTab("layout") + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end) + ooCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(-by)) + ooCb:SetWidth(bodyWidth - 16) + ooCb.tooltip = L["Only show other players' casts of these buffs."] + by = by - 34 + end + + -- ── APPEARANCE (collapsible — the effect-card section idiom) ── + by = by - 10 + by = AddGroupAppearanceSection(body, group, bodyWidth, by, expandKey) + end + + local bodyH = -by + 12 + body:SetHeight(bodyH) + totalCardH = totalCardH + bodyH + end + + card:SetHeight(totalCardH) + yPos = yPos - totalCardH - 5 + end + end + + parent:SetHeight(max(-yPos + 20, 200)) +end + +-- ============================================================ +-- DEBUFFS TAB — DEBUFF CATEGORY GROUPS (C2) +-- Each card owns a SELECTION of the native 12.1 debuff categories (checkbox +-- picker reusing the Aura Filters debuff column's controls) plus the shared +-- filter-group layout controls. Groups are spec-independent +-- (adDB.debuffGroups; lazily created on the first add — visiting the tab +-- writes nothing). Categories claimed by an enabled group are dropped from +-- the main debuff bar (C1 row-claim dedup), so EVERY selection / eye / +-- delete / add edit runs the FULL structural chain: InvalidateAuraLayout +-- bumps the version the row's claim fold AND the factory's dgroup record +-- cache re-resolve on; UpdateAllFrames + ForceRefreshAllFrames re-drive the +-- live frames and AD containers. Card keys are "dgroup:" in +-- expandedGroups — layout-group cards key by raw numeric id, so the string +-- prefix can never collide with them (the two id counters overlap). +-- ============================================================ + +S.BuildDebuffGroupsTab = function() + if not S.tabContentFrame then return end + local parent = S.tabContentFrame + local yPos = -10 + local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab accent + + -- Category picker rows: the Aura Filters debuff column's exact L keys + + -- tooltips, mapped onto group.selection's keys. Built per tab build so a + -- runtime locale overlay is picked up (file-scope L tables freeze enUS). + local CATEGORY_DEFS = { + { key = "boss", label = L["Boss Debuffs"], tooltip = L["Debuffs applied by dungeon and raid bosses."] }, + { key = "role", label = L["Role Debuffs"], tooltip = L["Debuffs Blizzard flags as important for your role."] }, + { key = "priority", label = L["Priority Debuffs"], tooltip = L["Debuffs Blizzard flags as high priority."] }, + { key = "crowdControl", label = L["Crowd Control"], tooltip = L["CC effects like stuns, roots, and incapacitates."] }, + { key = "raid", label = L["Raid Debuffs"], tooltip = L["Other debuffs Blizzard flags for raid frames."] }, + { key = "dispellable", label = L["Dispellable Debuffs"], tooltip = L["Debuffs that can be dispelled. Use the dropdown below to choose which dispels count."] }, + } + + -- FULL structural refresh incl. tab rebuild — discrete edits (category + -- checkboxes, dispel mode, hide-long controls, eye, delete, add): the + -- claims union moves AND the card summary / grey states must rebuild. + local function StructuralDebuffGroupRefresh() + S.SwitchTab("layout") + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end + + -- Same structural chain WITHOUT the tab rebuild — slider/dropdown-safe + -- (a S.SwitchTab from inside a slider callback destroys the widget + -- mid-interaction). Layout edits don't move the claims union, but the + -- version bump keeps the factory's version-keyed dgroup record cache + -- honest and costs one sig-gated re-resolve (offsets → ApplyStyle, + -- maxIcons → Rebuild). + local function LayoutDebuffGroupRefresh() + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end + + -- "+ Debuff Group" (prominent, tab accent — mirrors "+ Create Group"). + -- The debuffGroups array is born lazily on this first add. + local addBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + addBtn:SetHeight(32) + addBtn:SetPoint("TOPLEFT", 8, yPos) + addBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, yPos) + GUI:StyleButton(addBtn, { height = 32, primary = true, accent = gc, text = L["+ Debuff Group"], font = "DFFontHighlight" }) + addBtn:SetScript("OnClick", function() + local group = CreateDebuffGroup() + if group then + expandedGroups["dgroup:" .. group.id] = true + -- A new group claims Boss + Role by default, so the main debuff + -- bar drops them immediately: full structural chain, not just a + -- tab rebuild. + StructuralDebuffGroupRefresh() + end + end) + yPos = yPos - 42 + + -- Dedup explainer (§11.1 mock): how the row-claim handoff behaves. + local dedupHint = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + dedupHint:SetPoint("TOPLEFT", 8, yPos) + dedupHint:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + dedupHint:SetJustifyH("LEFT") + dedupHint:SetWordWrap(true) + dedupHint:SetText(L["Categories shown here are hidden from the main debuff bar automatically."]) + dedupHint:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 30 + + -- ── DEBUFF GROUPS heading — mirrors the Layout Groups tab's caption. ── + local groupsHeader = parent:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(groupsHeader, 9, "") + groupsHeader:SetPoint("TOPLEFT", 8, yPos) + groupsHeader:SetText(L["DEBUFF GROUPS"]) + groupsHeader:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 16 + + -- READ path: visiting the tab never creates adDB.debuffGroups. + local groups = DebuffGroupsRead() + + if #groups == 0 then + -- Empty state + local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) + empty:SetWidth(220) + empty:SetText(L["No debuff groups created yet.\nClick '+ Debuff Group' to get started."]) + empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) + empty:SetJustifyH("CENTER") + else + for _, group in ipairs(groups) do + local cardKey = "dgroup:" .. group.id + local isExpanded = expandedGroups[cardKey] or false + local capturedGroupID = group.id + + -- ── CARD + HEADER ── + local card, header, chevron = CreateCardShell(parent, { + yPos = yPos, + expanded = isExpanded, + borderColor = {r = gc.r * 0.35, g = gc.g * 0.35, b = gc.b * 0.35, a = 0.5}, + chevronColor = gc, + }) + + -- Group name + collapsed summary: the selected category names + -- (the A5 collapsed treatment, categories instead of link count). + local nameText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameText:SetPoint("LEFT", chevron, "RIGHT", 6, 0) + nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) + nameText:SetMaxLines(1) + local selectedNames = {} + local selRead = group.selection + if selRead then + for _, def in ipairs(CATEGORY_DEFS) do + if selRead[def.key] then tinsert(selectedNames, def.label) end + end + end + local summary = (#selectedNames > 0) and table.concat(selectedNames, ", ") + or L["No categories selected"] + nameText:SetText(group.name .. " - " .. summary) + nameText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Delete button — full structural chain: the group's claimed + -- categories return to the main debuff bar. + local delBtn = GUI:CreateCloseButton(header, { + size = 22, + onClick = function() + DeleteDebuffGroup(capturedGroupID) + StructuralDebuffGroupRefresh() + end, + }) + delBtn:SetPoint("RIGHT", -4, 0) + delBtn:SetFrameLevel(header:GetFrameLevel() + 2) + + -- Eye icon (visibility toggle) — same asset + idiom as the filter + -- group eye (A3/A5). Toggling is STRUCTURAL: the factory tears + -- down / stands up the container and the claims union moves. + local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) + eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) + local function shown() return group.enabled ~= false end + -- SetGlyph makes the state colour the new REST colour, so OnLeave + -- restores the state; hover is suppressed while hidden. + local function updateEyeIcon() + if shown() then + eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) + else + eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) + end + eyeBtn:SetGlyphHover(shown()) + end + updateEyeIcon() + eyeBtn:RegisterForClicks("LeftButtonUp") + eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) + eyeBtn:SetScript("OnClick", function() + group.enabled = (group.enabled == false) and true or false + updateEyeIcon() + StructuralDebuffGroupRefresh() + end) + if not shown() then + nameText:SetAlpha(0.5) + end + + -- Header click → toggle expansion + header:SetScript("OnClick", function() + expandedGroups[cardKey] = not expandedGroups[cardKey] + S.SwitchTab("layout") + end) + header:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + header:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + local totalCardH = 30 + + -- ── BODY (when expanded) ── + if isExpanded then + local body = CreateFrame("Frame", nil, card, "BackdropTemplate") + body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) + body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) + ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, + {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) + + local by = -10 + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 + if bodyWidth < 100 then bodyWidth = 240 end + + -- Repair a missing selection table (hand-edited data). All + -- categories start FALSE so an unconfigured group renders — + -- and claims — nothing until the user picks; modifier + -- defaults mirror CreateDebuffGroup. + local sel = group.selection + if not sel then + sel = { dispellableMode = "PLAYER", hideLongMinutes = 5, keepImportant = true } + group.selection = sel + end + + -- Group Name (editable) + local nameLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(nameLabel, 8, "") + nameLabel:SetPoint("TOPLEFT", 8, by) + nameLabel:SetText(L["GROUP NAME"]) + nameLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 16 + + local nameEdit = CreateFrame("EditBox", nil, body, "BackdropTemplate") + nameEdit:SetHeight(22) + nameEdit:SetPoint("TOPLEFT", 8, by) + nameEdit:SetPoint("RIGHT", body, "RIGHT", -8, 0) + nameEdit:SetAutoFocus(false) + nameEdit:SetText(group.name) + nameEdit:SetMaxLetters(30) + GUI:StyleEditBox(nameEdit, {}) + nameEdit:SetScript("OnEnterPressed", function(self) + local val = self:GetText() + if val and val ~= "" then + group.name = val + end + self:ClearFocus() + -- Name feeds no sig — cosmetic only (header + canvas label). + S.SwitchTab("layout") + RefreshPlacedIndicators() + end) + nameEdit:SetScript("OnEscapePressed", function(self) + self:SetText(group.name) + self:ClearFocus() + end) + by = by - 32 + + -- ── CATEGORIES SECTION ── + -- Checkbox list writing group.selection.* — every toggle is + -- structural (records move AND the claims union moves). + local catLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(catLabel, 8, "") + catLabel:SetPoint("TOPLEFT", 8, by) + catLabel:SetText(L["CATEGORIES"]) + catLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + for _, def in ipairs(CATEGORY_DEFS) do + local cb = GUI:CreateCheckbox(body, def.label, sel, def.key, StructuralDebuffGroupRefresh) + cb:SetPoint("TOPLEFT", 8, by) + cb.tooltip = def.tooltip + by = by - 26 + + if def.key == "dispellable" then + -- Mode dropdown, indented under Dispellable Debuffs + -- (the Aura Filters column's control, group-scoped; + -- was a two-state toggle until the PTR-5 DISPELLABLE + -- token added the third "Any Dispel Type" mode). + local modeDrop = GUI:CreateDropdown(body, L["Mode"], { + PLAYER = L["Dispellable By Me"], + ALL = L["All Dispellable"], + ANY = L["Any Dispel Type"], + _order = { "PLAYER", "ALL", "ANY" }, + }, sel, "dispellableMode", StructuralDebuffGroupRefresh) + modeDrop:SetPoint("TOPLEFT", 24, by) + if modeDrop.SetWidth then modeDrop:SetWidth(bodyWidth - 30) end + modeDrop.tooltip = L["Dispellable By Me: only debuffs you can dispel. All Dispellable: any debuff that can be dispelled. Any Dispel Type: every debuff with a dispel type, even ones that cannot be dispelled."] + modeDrop:SetEnabled(not not sel.dispellable) + by = by - 54 + end + end + + by = by - 4 + local hideLongCb = GUI:CreateCheckbox(body, L["Hide Long Debuffs"], sel, "hideLong", StructuralDebuffGroupRefresh) + hideLongCb:SetPoint("TOPLEFT", 8, by) + hideLongCb.tooltip = L["Hide debuffs whose total duration is longer than the threshold. Debuffs with no duration (permanent auras) are also hidden while this is on."] + by = by - 26 + + local minSlider = GUI:CreateSlider(body, L["Hide Longer Than (minutes)"], 1, 30, 1, + sel, "hideLongMinutes", LayoutDebuffGroupRefresh) + minSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 8, by) + if minSlider.SetWidth then minSlider:SetWidth(bodyWidth - 10) end + minSlider:SetEnabled(not not sel.hideLong) + by = by - 54 + + -- Keep-important, indented under Hide Long (Aura Filters idiom) + local keepCb = GUI:CreateCheckbox(body, L["Keep important debuffs"], sel, "keepImportant", StructuralDebuffGroupRefresh) + keepCb:SetPoint("TOPLEFT", 24, by) + keepCb.tooltip = L["Boss, Role, and Priority debuffs stay visible even when their duration is over the threshold."] + keepCb:SetEnabled(not not sel.hideLong) + by = by - 30 + + -- ── PLACEMENT SECTION ── (the shared filter-group layout controls) + by = by - 10 + local placeLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(placeLabel, 8, "") + placeLabel:SetPoint("TOPLEFT", 8, by) + placeLabel:SetText(L["PLACEMENT"]) + placeLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + local anchorDrop = GUI:CreateDropdown(body, L["Anchor"], OPTS.ANCHOR_OPTIONS, group, "anchor", LayoutDebuffGroupRefresh) + anchorDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if anchorDrop.SetWidth then anchorDrop:SetWidth(bodyWidth - 10) end + by = by - 54 + + local oxSlider = GUI:CreateSlider(body, L["Offset X"], -150, 150, 1, group, "offsetX", + LayoutDebuffGroupRefresh, RefreshPlacedIndicators) + oxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if oxSlider.SetWidth then oxSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + local oySlider = GUI:CreateSlider(body, L["Offset Y"], -150, 150, 1, group, "offsetY", + LayoutDebuffGroupRefresh, RefreshPlacedIndicators) + oySlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if oySlider.SetWidth then oySlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- ── GROWTH SECTION ── + by = by - 10 + local growLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(growLabel, 8, "") + growLabel:SetPoint("TOPLEFT", 8, by) + growLabel:SetText(L["GROWTH"]) + growLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 18 + + local growthControl = GUI:CreateGrowthControl(body, group, "growDirection", LayoutDebuffGroupRefresh) + growthControl:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if growthControl.SetWidth then growthControl:SetWidth(bodyWidth - 10) end + by = by - 158 + + local iprSlider = GUI:CreateSlider(body, L["Icons Per Row"], 1, 20, 1, group, "iconsPerRow", + LayoutDebuffGroupRefresh, RefreshPlacedIndicators) + iprSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if iprSlider.SetWidth then iprSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + local spacingSlider = GUI:CreateSlider(body, L["Spacing"], -5, 20, 1, group, "spacing", + LayoutDebuffGroupRefresh, RefreshPlacedIndicators) + spacingSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if spacingSlider.SetWidth then spacingSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- Uniform per-group styling (filter-group idiom: one icon + -- size + slot cap for everything the categories match). + local sizeSlider = GUI:CreateSlider(body, L["Icon Size"], 8, 64, 1, group, "iconSize", + LayoutDebuffGroupRefresh, RefreshPlacedIndicators) + sizeSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if sizeSlider.SetWidth then sizeSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- Max Icons is STRUCTURAL (slot count is declared at container + -- build) — the factory Rebuild path handles it (OOC-deferred). + local maxSlider = GUI:CreateSlider(body, L["Max Icons"], 1, 20, 1, group, "maxIcons", + LayoutDebuffGroupRefresh, RefreshPlacedIndicators) + maxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if maxSlider.SetWidth then maxSlider:SetWidth(bodyWidth - 10) end + by = by - 54 + + -- ── SORT (Wave 2) ── per-group sort is TUNING: the factory + -- re-applies it in place via ApplyTuning (OOC-immediate; + -- self-defers in combat) — no rebuild. Fields are OPTIONAL on + -- the group (othersOnly idiom): absent = the family default + -- "TIME" (soonest-to-expire first — the old hardcode), read + -- through a customGet so legacy groups display correctly + -- without a write-on-open. + local sortMineCb -- forward capture: the dropdown greys it + local sortDrop = GUI:CreateDropdown(body, L["Sort Order"], OPTS.SORT_OPTIONS, nil, nil, function() + LayoutDebuffGroupRefresh() + if sortMineCb then sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "TIME")) end + end, function() return group.sortOrder or "TIME" end, + function(v) group.sortOrder = v end) + sortDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) + if sortDrop.SetWidth then sortDrop:SetWidth(bodyWidth - 10) end + by = by - 54 + + sortMineCb = GUI:CreateCheckbox(body, L["My Auras First"], group, "sortMineFirst", LayoutDebuffGroupRefresh) + sortMineCb:SetPoint("TOPLEFT", 8, by) + sortMineCb:SetWidth(bodyWidth - 16) + sortMineCb.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] + sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "TIME")) + by = by - 26 + + local sortRevCb = GUI:CreateCheckbox(body, L["Reverse Order"], group, "sortReverse", LayoutDebuffGroupRefresh) + sortRevCb:SetPoint("TOPLEFT", 8, by) + sortRevCb:SetWidth(bodyWidth - 16) + sortRevCb.tooltip = L["Reverse the sort direction."] + by = by - 30 + + -- ── APPEARANCE (collapsible — the effect-card section idiom) ── + by = by - 10 + by = AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) + + local bodyH = -by + 12 + body:SetHeight(bodyH) + totalCardH = totalCardH + bodyH + end + + card:SetHeight(totalCardH) + yPos = yPos - totalCardH - 5 + end + end + + parent:SetHeight(max(-yPos + 20, 200)) +end + +-- ============================================================ +-- MAIN PAGE BUILD +-- ============================================================ + +function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) + local prevDB = S.db -- capture before overwrite to detect mode switch + S.page = pageRef + S.db = dbRef + + local parent = S.page.child + + -- ======================================== + -- REUSE: If S.mainFrame already exists, S.db hasn't changed (same mode) AND the + -- frame dimensions are unchanged, just re-parent, show, and refresh. A mode + -- switch (Party↔Raid) changes S.db; an auto-layout switch keeps the SAME S.db + -- reference but changes frameWidth/Height — both must force a full rebuild so + -- the preview mock resizes to the active layout's frame size. + -- ======================================== + local _adFDB = (DF.GetDB and DF:GetDB((GUI and GUI.SelectedMode) or "party")) or {} + local _adW, _adH = _adFDB.frameWidth or 125, _adFDB.frameHeight or 64 + -- Auto-layout identity: two raid layouts share the SAME S.db proxy and may share + -- frame dimensions, so neither check below distinguishes them — without this, + -- switching between same-size raid layouts reuses the stale S.page. + local _adLayout = (DF.AutoProfilesUI and (DF.AutoProfilesUI.editingProfile or DF.AutoProfilesUI.activeRuntimeProfile)) or nil + -- Preset identity: switching the mode's preset keeps the same S.db/size/layout, + -- so without this the stale S.page (bound to the old preset) would be reused. + local _adPreset = DF.GetModeDesignerPresetName + and DF:GetModeDesignerPresetName("aura", (GUI and GUI.SelectedMode) or "party") + -- Editing identity: entering edit of the ACTIVE layout keeps the same table + -- object (editingProfile == activeRuntimeProfile), so _adLayout alone + -- misses the transition and the editing-banner offset is never applied. + local _adEditing = (DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing()) or false + if S.mainFrame and prevDB == dbRef + and S.mainFrame.dfBuiltFrameW == _adW and S.mainFrame.dfBuiltFrameH == _adH + and S.mainFrame.dfBuiltLayout == _adLayout + and S.mainFrame.dfBuiltPreset == _adPreset + and S.mainFrame.dfBuiltEditing == _adEditing then + S.mainFrame:SetParent(parent) + S.mainFrame:SetAllPoints() + S.mainFrame:Show() + DF:AuraDesigner_RefreshPage() + return + end + + -- Full build (first time, or mode switch) + if S.mainFrame then + S.mainFrame:Hide() + S.mainFrame:SetParent(nil) + end + wipe(placedIndicators) + wipe(expandedCards) + wipe(effectCardPool) + + S.activeTab = "effects" + S.activeBuffTab = "my" + S.activeFilter = "all" + -- A shared picker left open on the OLD S.rightPanel dies with it (its + -- close hook may already have run via the ancestor hide); drop the + -- handle so CloseADPicker can't poke a stale overlay. + S.adPickerDirty = false + S.adPickerHandle = nil + + -- Layout constants + local BANNER_H = 68 + local BUFFTAB_H = 30 -- main pool tab strip (My Buffs / Other Buffs) + local SECTION_GAP = 8 + + -- ======================================== + -- MAIN FRAME + -- ======================================== + S.mainFrame = CreateFrame("Frame", nil, parent) + S.mainFrame:SetAllPoints() + -- Record the frame dims this build was made for, so the reuse-guard above can + -- detect an auto-layout switch (same S.db, different frameWidth/Height) and rebuild. + S.mainFrame.dfBuiltFrameW = _adW + S.mainFrame.dfBuiltFrameH = _adH + S.mainFrame.dfBuiltLayout = _adLayout + S.mainFrame.dfBuiltPreset = _adPreset + S.mainFrame.dfBuiltEditing = _adEditing + -- Closing the settings window (or leaving this S.page) hides S.mainFrame with + -- no refresh pass, which would leave the rendered preview pool's border + -- animations ticking on the external driver (it ticks hidden secretRect + -- borders — see ClearPlacedIndicators). OnHide fires on effective-visibility + -- loss, so an ancestor hide (window close, S.page switch) reaches it too; the + -- reuse path re-renders via AuraDesigner_RefreshPage → RefreshPlacedIndicators + -- on return. S.mainFrame is created fresh per full build (the old one is hidden + -- and unparented above), so this hook lands exactly once per frame. + S.mainFrame:HookScript("OnHide", ClearPlacedIndicators) + + -- Override RefreshStates: Aura Designer uses its own layout system. + -- + -- This hook gets called by anything that walks the GUI parent chain + -- looking for a S.page with RefreshStates+children — including + -- CreateInfoBanner's TriggerHostRelayout after every measure cycle. + -- AuraDesigner_RefreshPage is a heavyweight rebuild (destroys + + -- recreates every effect card on the active tab), so firing it + -- from a banner's auto-resize cascade meant: each new banner from + -- S.BuildEffectsTab triggered SetText → schedule DoRecomputeHeight → + -- TriggerHostRelayout → S.page:RefreshStates → AuraDesigner_RefreshPage + -- → S.SwitchTab → S.BuildEffectsTab → create more banners → repeat at + -- ~9 Hz, locking up the GUI the moment the perf-warning banner + -- surfaced (because picking an animation triggered the chain). + -- + -- The fix: only call AuraDesigner_RefreshPage when the S.page + -- dimensions actually changed. GUI window resize cases (the real + -- reason this hook exists) still rebuild; banner-cascade-as-noop + -- cases stop the loop. + S.page.RefreshStates = function(self) + local pageH = self:GetHeight() + self.child:SetHeight(pageH) + local newW = GUI.contentFrame and (GUI.contentFrame:GetWidth() - 30) or nil + if self.child and newW then + self.child:SetWidth(newW) + end + -- Keep parent scroll at 0 — only the right panel should scroll + local parentScroll = self:GetParent() + if parentScroll and parentScroll.SetVerticalScroll then + parentScroll:SetVerticalScroll(0) + end + -- Skip the heavyweight rebuild when nothing actually changed — + -- only fire it on genuine size transitions (window resize / tab + -- switch / first show). + if self._lastRefreshStatesH == pageH and self._lastRefreshStatesW == newW then + -- Same-size revisit AFTER the OnHide hook cleared the canvas (tab + -- away + back re-enters ONLY through RefreshCached -> here; the + -- builder is cache-skipped). Repaint the pooled slots without the + -- full S.page rebuild: slot restyling creates no banners, so the + -- banner-cascade loop this early-out exists for cannot re-arm. + if S.placedCleared then + RefreshPlacedIndicators() + RefreshPreviewEffects() + end + return + end + self._lastRefreshStatesH = pageH + self._lastRefreshStatesW = newW + DF:AuraDesigner_RefreshPage() + end + + local yPos = -8 -- top gap; kept equal to the Text Designer's _tdTopY for a consistent header gap + -- While editing a raid auto-layout, the AutoProfiles editing banner is a ~50px + -- overlay anchored to the top of the content frame; this custom AD S.page lays + -- its own content out from the top too, so push everything down to clear it + -- (otherwise the editing banner sits on top of the enable banner / preset bar). + if DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() then + yPos = -56 + end + + -- ======================================== + -- ENABLE BANNER (full width) + -- ======================================== + S.enableBanner = CreateEnableBanner(S.mainFrame) + S.enableBanner:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) + S.enableBanner:SetPoint("TOPRIGHT", S.mainFrame, "TOPRIGHT", 0, yPos) + + -- No Copy / Sync pair here (every other mode-specific S.page has one). The one + -- key this S.page owns is the template NAME, and the template bar below sets + -- it directly — so Copy was "pick that name in the other tab" and Sync was a + -- link that could only ever hold one name in step. Sharing is now stated and + -- undone on the bar itself; see GUI:CreateDesignerPresetBar. + + -- ======================================== + -- PRESET BAR (which named preset this mode uses + library management) + -- Rides row 2 of the enable banner (left side; the spec dropdown holds the + -- right side). Compact icon buttons keep the row within the S.page width. + -- ======================================== + if GUI.CreateDesignerPresetBar then + local presetBar = GUI:CreateDesignerPresetBar(S.enableBanner, { + kind = "aura", + iconButtons = true, + getMode = function() return (GUI and GUI.SelectedMode) or "party" end, + onChange = function() + -- Re-invoke the build NEXT frame: the dfBuiltPreset guard then + -- forces a full rebuild so the editor rebinds to the newly chosen + -- preset. Deferred so we don't tear down the bar from inside its + -- own click handler. + if C_Timer and C_Timer.After then + C_Timer.After(0, function() + if DF.BuildAuraDesignerPage then DF.BuildAuraDesignerPage(GUI, S.page, S.db) end + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + local E = DF.AuraDesigner and DF.AuraDesigner.Engine + if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end + end) + end + end, + }) + -- Row 2 reflows (B2): the spec dropdown moved onto the tab strip, so + -- the preset bar takes the whole row. + presetBar:SetPoint("LEFT", S.enableBanner, "LEFT", 10, -18) + presetBar:SetPoint("RIGHT", S.enableBanner, "RIGHT", -10, -18) + S.enableBanner.presetBar = presetBar + end + + yPos = yPos - (BANNER_H + SECTION_GAP) + + -- ======================================== + -- MAIN POOL TAB STRIP (B2/C2): My Buffs / Debuffs / Other Buffs, between the header + -- banner and the workspace. Visually distinct from the right panel's + -- underline sub-tabs: filled StyleButton pills, slightly larger, with the + -- relocated spec dropdown on the strip's right end (per the prototype — + -- the spec only applies to My Buffs). + -- ======================================== + local buffTabBar = CreateFrame("Frame", nil, S.mainFrame) + buffTabBar:SetHeight(BUFFTAB_H) + buffTabBar:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) + buffTabBar:SetPoint("TOPRIGHT", S.mainFrame, "TOPRIGHT", 0, yPos) + + -- The three pools differ on two axes the labels can't carry — WHOSE casts + -- count, and whether the pool is per-spec — so each tab explains itself on + -- hover. (Any Buff is caster-agnostic by default; "Others Only" is the + -- per-effect opt-in that ignores your own casts.) + local MAIN_TAB_DEFS = { + { key = "my", label = L["My Buffs"], tooltip = { + L["Buffs from your own class, and only when you cast them."], + L["Set up separately for each specialization."], + } }, + { key = "debuffs", label = L["Debuffs"], tooltip = { + L["Groups of debuffs picked by category — boss, crowd control, dispellable and so on — rather than one spell at a time."], + L["Shared across all your specializations."], + } }, + { key = "other", label = L["Any Buff"], tooltip = { + L["Any buff in the spell database, from any caster — including your own."], + L["Turn on Others Only for an effect to ignore your own casts."], + L["Shared across all your specializations."], + } }, + } + wipe(mainTabButtons) + local prevMainBtn + for _, def in ipairs(MAIN_TAB_DEFS) do + local btn = CreateFrame("Button", nil, buffTabBar, "BackdropTemplate") + GUI:StyleButton(btn, { height = BUFFTAB_H - 4, text = def.label, font = "DFFontHighlight" }) + btn.Text:ClearAllPoints() + btn.Text:SetPoint("CENTER", 0, 0) + btn:SetWidth(max(btn.Text:GetStringWidth() + 28, 96)) + if prevMainBtn then + btn:SetPoint("LEFT", prevMainBtn, "RIGHT", 4, 0) + else + btn:SetPoint("LEFT", buffTabBar, "LEFT", 0, 0) + end + local capturedKey = def.key + btn:SetScript("OnClick", function() SetMainTab(capturedKey) end) + -- HookScript, not SetScript: StyleButton owns OnEnter/OnLeave for the + -- hover wash, and replacing them would leave the tab stuck lit. + local tipTitle, tipLines = def.label, def.tooltip + btn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = tipTitle, lines = tipLines }) + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + btn:SetActive(S.activeBuffTab == def.key) + mainTabButtons[def.key] = btn + prevMainBtn = btn + end + + -- Relocated spec dropdown (right end of the strip) + local stripSpecLabel = buffTabBar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + stripSpecLabel:SetText(L["Spec:"]) + stripSpecLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + S.specDropdown, S.specDropdownUpdate = CreateSpecDropdown(buffTabBar) + S.specDropdown:SetSize(165, 22) + S.specDropdown:SetPoint("RIGHT", buffTabBar, "RIGHT", -5, 0) + stripSpecLabel:SetPoint("RIGHT", S.specDropdown, "LEFT", -4, 0) + UpdateSpecDropdownState() + + yPos = yPos - (BUFFTAB_H + SECTION_GAP) + + -- ======================================== + -- 50/50 SPLIT: LEFT PANEL + RIGHT PANEL + -- ======================================== + local splitContainer = CreateFrame("Frame", nil, S.mainFrame) + splitContainer:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) + splitContainer:SetPoint("BOTTOMRIGHT", S.mainFrame, "BOTTOMRIGHT", 0, 0) + S.mainFrame.splitContainer = splitContainer + + -- ── LEFT PANEL (frame preview) ── + S.leftPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") + S.leftPanel:SetPoint("TOPLEFT", 0, 0) + S.leftPanel:SetPoint("BOTTOMLEFT", 0, 0) + S.leftPanel:SetPoint("RIGHT", splitContainer, "CENTER", -3, 0) + -- NO border here — the inner preview container (CreateFramePreview) draws the + -- visible dim border. A border on both stacked to a brighter doubled line. + GUI:CreatePanelBackdrop(S.leftPanel, {border = false}) + + -- Frame preview (reuses existing CreateFramePreview with adapted anchoring) + S.origY_framePreview = 0 + S.framePreview = CreateFramePreview(S.leftPanel, 0, nil) + S.contentRightInset = 0 -- No right inset needed in new layout + + -- ── RIGHT PANEL (tabbed settings) ── + S.rightPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") + S.rightPanel:SetPoint("TOPRIGHT", 0, 0) + S.rightPanel:SetPoint("BOTTOMRIGHT", 0, 0) + S.rightPanel:SetPoint("LEFT", splitContainer, "CENTER", 3, 0) -- 6px split gap (matches Text Designer) + GUI:CreatePanelBackdrop(S.rightPanel, {borderColor = {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}}) + + -- ── TAB BAR ── (shared underline-tab style, mirroring the Pinned Frames + -- tabs: a transparent strip with a baseline; each tab is a StyleButton in + -- `tab` mode — faint cell when inactive, accent fill + underline + accent + -- label when active. Per-tab accent preserves each section's identity.) + S.tabBar = CreateFrame("Frame", nil, S.rightPanel, "BackdropTemplate") + S.tabBar:SetHeight(28) + -- Inset just inside the panel's border so the tabs don't overlap/overrun it. + S.tabBar:SetPoint("TOPLEFT", 4, -4) + S.tabBar:SetPoint("TOPRIGHT", -4, -4) + + -- Baseline under the whole strip; the active tab's underline sits on it. + local tabBaseline = S.tabBar:CreateTexture(nil, "ARTWORK") + tabBaseline:SetTexture("Interface\\Buttons\\WHITE8x8") + tabBaseline:SetHeight(1) + tabBaseline:SetPoint("BOTTOMLEFT", 0, 0) + tabBaseline:SetPoint("BOTTOMRIGHT", 0, 0) + tabBaseline:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + + local TAB_GAP = 4 + local TAB_DEFS = { + { key = "effects", label = L["Effects"], accent = nil }, -- theme-tracking + { key = "layout", label = L["Layout Groups"], accent = { r = 0.91, g = 0.66, b = 0.25 } }, + { key = "global", label = L["Global"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, + } + + wipe(tabButtons) + for i, def in ipairs(TAB_DEFS) do + local btn = CreateFrame("Button", nil, S.tabBar, "BackdropTemplate") + btn:SetHeight(28) + if i == 1 then + btn:SetPoint("TOPLEFT", 0, 0) + else + btn:SetPoint("TOPLEFT", tabButtons[TAB_DEFS[i-1].key], "TOPRIGHT", TAB_GAP, 0) + end + local provW = parent:GetWidth() + if provW < 100 and GUI and GUI.contentFrame then provW = GUI.contentFrame:GetWidth() end + if provW < 100 then provW = 600 end + btn:SetWidth(max(60, floor(((provW / 2) - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS))) + + -- Shared underline-tab styling; S.SwitchTab drives SetActive. label = btn.Text. + GUI:StyleButton(btn, { tab = true, text = def.label, accent = def.accent, font = "DFFontHighlight" }) + btn.label = btn.Text + + btn.tabKey = def.key + btn:SetScript("OnClick", function(self) + if self.dfDisabled then return end -- frosted (Effects on Debuffs) + S.SwitchTab(self.tabKey) + end) + + -- Effects is buff-pool-only (C2): frosted on the Debuffs tab — + -- category groups have no per-spell placed indicators. + if def.key == "effects" then + btn:HookScript("OnEnter", function(self) + if self.dfDisabled then + GUI:ShowTooltip(self, { + title = L["Effects"], + lines = { L["Not available for Debuffs. Use Layout Groups instead."] }, + }) + end + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + + tabButtons[def.key] = btn + end + UpdateLayoutTabState() + + -- Equal-width tabs (accounting for the gaps) on parent resize. + S.tabBar:SetScript("OnSizeChanged", function(self, w, h) + local n = #TAB_DEFS + local tabW = (w - (n - 1) * TAB_GAP) / n + for _, def in ipairs(TAB_DEFS) do + local btn = tabButtons[def.key] + if btn then btn:SetWidth(tabW) end + end + end) + + -- ── TAB CONTENT (scrollable) ── + S.tabScrollFrame = CreateFrame("ScrollFrame", nil, S.rightPanel, "ScrollFrameTemplate") + S.tabScrollFrame:SetPoint("TOPLEFT", S.tabBar, "BOTTOMLEFT", 0, 0) + S.tabScrollFrame:SetPoint("BOTTOMRIGHT", -22, 0) + + S.tabContentFrame = CreateFrame("Frame", nil, S.tabScrollFrame) + -- Pre-compute initial width from parent geometry so S.SwitchTab() has + -- accurate dimensions before the first layout pass fires OnSizeChanged. + local earlyW = parent:GetWidth() + if earlyW < 100 then earlyW = (GUI.contentFrame and GUI.contentFrame:GetWidth() or 600) - 30 end + S.tabContentFrame:SetWidth(max(1, (earlyW / 2) - 2 - 22)) + S.tabContentFrame:SetHeight(800) + S.tabScrollFrame:SetScrollChild(S.tabContentFrame) + DF.GUI.StyleScrollBar(S.tabScrollFrame) + + -- Match scroll child width to scroll frame + S.tabScrollFrame:SetScript("OnSizeChanged", function(self, w, h) + S.tabContentFrame:SetWidth(w) + end) + + -- Smooth scroll + local SCROLL_STEP = 30 + S.tabScrollFrame:SetScript("OnMouseWheel", function(self, delta) + local current = self:GetVerticalScroll() + local maxScroll = max(0, self:GetVerticalScrollRange()) + local newScroll = max(0, min(maxScroll, current - (delta * SCROLL_STEP))) + self:SetVerticalScroll(newScroll) + end) + S.tabContentFrame:EnableMouseWheel(true) + S.tabContentFrame:SetScript("OnMouseWheel", function(self, delta) + local p = self:GetParent() + if p and p:GetScript("OnMouseWheel") then + p:GetScript("OnMouseWheel")(p, delta) + end + end) + + -- ======================================== + -- POPULATE (new UI) + -- ======================================== + + -- Force initial width sync: OnSizeChanged won't fire until the frame renders, + -- but S.SwitchTab needs accurate widths now for slider/dropdown sizing. + -- Compute initial scroll content width from parent geometry. + -- S.rightPanel:GetWidth() returns 0 before the first layout pass, so we + -- calculate from the parent which already has valid geometry on a mode + -- switch (Party↔Raid). + local parentW = parent:GetWidth() + if parentW < 100 and GUI and GUI.contentFrame then parentW = GUI.contentFrame:GetWidth() end + if parentW < 100 then parentW = UIParent:GetWidth() / 2 end + local initW = (parentW / 2) - 2 - 22 -- half split minus gap minus scrollbar + if initW > 50 then + S.tabContentFrame:SetWidth(initW) + end + + S.SwitchTab("effects") + C_Timer.After(0, function() + if S.tabBar and S.tabBar:IsVisible() and S.tabBar:GetWidth() > 10 then + local tabW = (S.tabBar:GetWidth() - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS + for _, def in ipairs(TAB_DEFS) do + if tabButtons[def.key] then + tabButtons[def.key]:SetWidth(tabW) + end + end + end + end) + RefreshPlacedIndicators() + RefreshPreviewEffects() +end + +-- ============================================================ +-- REFRESH +-- ============================================================ + +function DF:AuraDesigner_RefreshPage() + if not S.mainFrame then return end + + -- Account for editing banner offset (50px) when editing an auto layout + local editingOffset = 0 + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + editingOffset = 50 + end + S.mainFrame:ClearAllPoints() + S.mainFrame:SetPoint("TOPLEFT", S.mainFrame:GetParent(), "TOPLEFT", 0, -editingOffset) + S.mainFrame:SetPoint("BOTTOMRIGHT", S.mainFrame:GetParent(), "BOTTOMRIGHT", 0, 0) + + -- Check if spec changed + local currentSpec = ResolveSpec() + if currentSpec ~= S.selectedSpec then + S.selectedSpec = currentSpec + end + + -- Subtle class-color hint on the preview border, dimmed to 0.5 alpha so it + -- stays as quiet as the Text Designer's neutral border (just tinted to the + -- spec). Was previously full alpha = the harsh "white line" (white for Priest). + if S.framePreview then + local resolvedSpec = currentSpec or S.selectedSpec + local specInfoEntry = resolvedSpec and DF.AuraDesigner.SpecInfo[resolvedSpec] + local classToken = specInfoEntry and specInfoEntry.class + local classColor = classToken and RAID_CLASS_COLORS[classToken] + if classColor then + S.framePreview:SetBackdropBorderColor(classColor.r, classColor.g, classColor.b, 0.5) + else + S.framePreview:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + end + end + + -- Rebuild the current tab to reflect data changes + if S.activeTab and S.SwitchTab then + S.SwitchTab(S.activeTab) + end + + -- Refresh frame preview + RefreshPlacedIndicators() + RefreshPreviewEffects() + + -- Update enable state + if S.enableBanner then + S.enableBanner.checkbox:SetChecked(GetAuraDesignerDB().enabled) + end + -- Spec dropdown lives on the main tab strip (B2): refresh its text on + -- My Buffs / keep the greyed "shared across specs" caption on Other Buffs. + UpdateSpecDropdownState() + + -- Show/hide disabled overlay on the split container + if S.mainFrame.splitContainer then + local adEnabled = GetAuraDesignerDB().enabled + if not adEnabled then + if not S.mainFrame.disabledOverlay then + -- Shared with the Text Designer and Raid Auto Layouts; this S.page + -- only owns the extent (the whole split container) and the label. + local overlay = GUI:CreateDisabledOverlay(S.mainFrame.splitContainer, { + label = L["Aura Designer is disabled"], + }) + overlay:SetAllPoints() + S.mainFrame.disabledOverlay = overlay + end + S.mainFrame.disabledOverlay:Show() + else + if S.mainFrame.disabledOverlay then + S.mainFrame.disabledOverlay:Hide() + end + end + end + + -- Refresh buffs tab banner state if visible + local buffsPage = GUI and GUI.Pages and GUI.Pages["auras_buffs"] + if buffsPage and buffsPage.RefreshStates then + buffsPage:RefreshStates() + end + + -- 12.1: the native factory buff row DERIVES its Aura-Designer dedup set from the AD + -- config at build time. Indicator add/remove (and other config mutations) funnel + -- through this refresh but do NOT otherwise re-drive live frames, so poke the buff + -- row here — mirror the aura-blacklist S.page (InvalidateAuraLayout -> RefreshFactoryRows). + -- DriveBuffFactory rebuilds only when the excluded set actually moved (sig-gated), so a + -- navigation-only refresh is a cheap no-op. Factory-gated so the pre-12.1 path is untouched. + if DF.AuraContainer and DF.AuraContainer.IsSupported and DF.AuraContainer.IsSupported() + and DF.InvalidateAuraLayout then + DF:InvalidateAuraLayout() + end +end diff --git a/AuraDesigner/UI/Groups.lua b/AuraDesigner/UI/Groups.lua new file mode 100644 index 00000000..158e9f90 --- /dev/null +++ b/AuraDesigner/UI/Groups.lua @@ -0,0 +1,2099 @@ +-- Part 2 of the Aura Designer editor, split from Options.lua. +-- Aliases of objects the first part created; they add no state. +local addonName, DF = ... +local L = DF.L +local GUI = DF.GUI +local Adapter = DF.AuraDesigner.Adapter +local S = DF.AuraDesigner._uiState +local P = DF.AuraDesigner._priv +local OPTS = P.OPTS +local GetAuraDesignerDB = P.GetAuraDesignerDB +local GetThemeColor = P.GetThemeColor +local ApplyBackdrop = P.ApplyBackdrop +local ResolveSpec = P.ResolveSpec +local GetSpecAuras = P.GetSpecAuras +local GetOtherAuras = P.GetOtherAuras +local NextGroupName = P.NextGroupName +local GetSpecLayoutGroups = P.GetSpecLayoutGroups +local IsOtherTab = P.IsOtherTab +local IsDebuffTab = P.IsDebuffTab +local EMPTY_POOL = P.EMPTY_POOL +local CurrentAuraPool = P.CurrentAuraPool +local PoolKeyPrefix = P.PoolKeyPrefix +local DebuffGroupsRead = P.DebuffGroupsRead +local GetOtherLayoutGroups = P.GetOtherLayoutGroups +local CurrentLayoutGroups = P.CurrentLayoutGroups +local OtherPoolDisplayName = P.OtherPoolDisplayName +local EnsureAuraConfig = P.EnsureAuraConfig +local EnsureTypeConfig = P.EnsureTypeConfig +local TYPE_DEFAULTS = P.TYPE_DEFAULTS +P.TYPE_DEFAULTS = TYPE_DEFAULTS + +-- ============================================================ +-- INSTANCE-BASED INDICATOR HELPERS +-- Placed indicators (icon/square/bar) are stored as instances +-- in auraCfg.indicators[] with stable IDs. +-- ============================================================ + +-- Create a new indicator instance for an aura, returns the instance table +local function CreateIndicatorInstance(auraName, typeKey) + local auraCfg = EnsureAuraConfig(auraName) + if not auraCfg.indicators then + auraCfg.indicators = {} + end + if not auraCfg.nextIndicatorID then + auraCfg.nextIndicatorID = 1 + end + + -- Only store id, type, and anchor — all other settings fall through + -- to global defaults then TYPE_DEFAULTS via CreateInstanceProxy + local defaults = TYPE_DEFAULTS[typeKey] + + -- Create minimal instance: just id + type + anchor placement + local instance = { + anchor = defaults and defaults.anchor or "TOPLEFT", + offsetX = 0, + offsetY = 0, + } + + instance.id = auraCfg.nextIndicatorID + instance.type = typeKey + auraCfg.nextIndicatorID = auraCfg.nextIndicatorID + 1 + + tinsert(auraCfg.indicators, instance) + return instance +end +P.CreateIndicatorInstance = CreateIndicatorInstance + +-- Find an indicator instance by its stable ID. `pool` (optional) pins the +-- pool; defaults to the active tab's pool. +local function GetIndicatorByID(auraName, indicatorID, pool) + local auraCfg = (pool or CurrentAuraPool())[auraName] + if not auraCfg or not auraCfg.indicators then return nil end + for _, inst in ipairs(auraCfg.indicators) do + if inst.id == indicatorID then + return inst + end + end + return nil +end + +-- Remove an indicator instance by its stable ID +-- (S.CleanupAdHocAura declared on the state table) +local function RemoveIndicatorInstance(auraName, indicatorID) + local auraCfg = CurrentAuraPool()[auraName] + if not auraCfg or not auraCfg.indicators then return end + for i, inst in ipairs(auraCfg.indicators) do + if inst.id == indicatorID then + table.remove(auraCfg.indicators, i) + if S.CleanupAdHocAura then S.CleanupAdHocAura(auraName) end + return + end + end +end +P.RemoveIndicatorInstance = RemoveIndicatorInstance + +-- (ChangeInstanceType removed — uncalled since the tile-strip type switcher +-- left the v4 redesign; reclaimed for the 200-locals ceiling.) + +-- Keys to skip when copying appearance between indicators (identity + placement + +-- the eye toggle's hidden state — copying appearance must not hide the destination) +local COPY_SKIP_KEYS = { id = true, type = true, anchor = true, offsetX = true, offsetY = true, enabled = true } + +-- Deep-copy a value (handles nested tables like color = {r,g,b,a}) +local function DeepCopyValue(val) + if type(val) == "table" then + local copy = {} + for k, v in pairs(val) do + copy[k] = DeepCopyValue(v) + end + return copy + end + return val +end + +-- Copy appearance settings from one placed indicator to another of the same type. +-- Copies all keys except identity (id, type) and placement (anchor, offsetX, offsetY). +-- Keys present on source are deep-copied; keys absent on source are removed from +-- destination so they fall through to defaults via the proxy chain. +local function CopyIndicatorAppearance(srcAuraName, srcIndicatorID, dstAuraName, dstIndicatorID) + local src = GetIndicatorByID(srcAuraName, srcIndicatorID) + local dst = GetIndicatorByID(dstAuraName, dstIndicatorID) + if not src or not dst then return end + if src.type ~= dst.type then return end + + -- Collect all non-skip keys from both source and destination + local allKeys = {} + for k in pairs(src) do + if not COPY_SKIP_KEYS[k] then allKeys[k] = true end + end + for k in pairs(dst) do + if not COPY_SKIP_KEYS[k] then allKeys[k] = true end + end + + -- Sync: copy from src, clear from dst what src doesn't have + for k in pairs(allKeys) do + if src[k] ~= nil then + dst[k] = DeepCopyValue(src[k]) + else + dst[k] = nil + end + end +end +P.CopyIndicatorAppearance = CopyIndicatorAppearance + +-- Forward declaration: lightweight preview refresh (defined after RefreshPreviewEffects) +-- Called from proxy __newindex so every setting change updates the preview in real-time +-- (S.RefreshPreviewLightweight declared on the state table) + +-- Throttled live-frame refresh: re-syncs the factory containers on all +-- visible AD-enabled frames. Debounced so rapid slider drags only trigger one refresh. +S.pendingLiveRefresh = false +local function RefreshLiveFramesThrottled() + if S.pendingLiveRefresh then return end + S.pendingLiveRefresh = true + C_Timer.After(0.1, function() + S.pendingLiveRefresh = false + local engine = DF.AuraDesigner and DF.AuraDesigner.Engine + if engine and engine.ForceRefreshAllFrames then + engine:ForceRefreshAllFrames() + end + end) +end +P.RefreshLiveFramesThrottled = RefreshLiveFramesThrottled + +-- Global-default key mapping: which global default keys apply to placed types +local GLOBAL_DEFAULT_MAP = { + icon = { + size = "iconSize", scale = "iconScale", showDuration = "showDuration", showStacks = "showStacks", + durationFont = "durationFont", durationScale = "durationScale", durationOutline = "durationOutline", + durationAnchor = "durationAnchor", durationX = "durationX", durationY = "durationY", + durationColorByTime = "durationColorByTime", durationColor = "durationColor", + stackFont = "stackFont", stackScale = "stackScale", stackOutline = "stackOutline", + stackAnchor = "stackAnchor", stackX = "stackX", stackY = "stackY", + stackColor = "stackColor", + hideSwipe = "hideSwipe", hideIcon = "hideIcon", + frameLevel = "indicatorFrameLevel", frameStrata = "indicatorFrameStrata", + }, + square = { + size = "iconSize", scale = "iconScale", showDuration = "showDuration", showStacks = "showStacks", + durationFont = "durationFont", durationScale = "durationScale", durationOutline = "durationOutline", + durationAnchor = "durationAnchor", durationX = "durationX", durationY = "durationY", + durationColorByTime = "durationColorByTime", durationColor = "durationColor", + stackFont = "stackFont", stackScale = "stackScale", stackOutline = "stackOutline", + stackAnchor = "stackAnchor", stackX = "stackX", stackY = "stackY", + stackColor = "stackColor", + hideSwipe = "hideSwipe", hideIcon = "hideIcon", + frameLevel = "indicatorFrameLevel", frameStrata = "indicatorFrameStrata", + }, + bar = { + durationFont = "durationFont", durationScale = "durationScale", durationOutline = "durationOutline", + durationAnchor = "durationAnchor", durationX = "durationX", durationY = "durationY", + durationColorByTime = "durationColorByTime", + frameLevel = "indicatorFrameLevel", frameStrata = "indicatorFrameStrata", + }, +} + +-- "Expiration" section for the placed icon/square cards: the per-indicator EXPIRY ALERT +-- ELEMENT (text / glyph / border / tint shown only below the threshold, natively driven on an +-- invisible COMPANION SLOT over the indicator — see Factory.lua's EXPIRY ALERT COMPANION SLOT +-- section). Built by the shared GUI:CreateExpirationControls helper (engine-driven via +-- DF.Expiration) so the frame-level indicators can reuse the exact same panel. Controls that +-- don't apply to the current mode HIDE (rows collapse + the card reflows); ones that apply but +-- are inactive GREY. No animation control: a button-child region can't be animated while auras +-- are secret (PTR-5), and out-of-combat-only animation is worthless for an expiry warning. +-- `include` (optional) selects which reveal types + controls apply to this indicator's shape: +-- square indicators (icon/square) pass nil (Border + Tint + Match); a rectangular one (bar) +-- passes { border = false, tint = false, match = false } — Border distorts off-square, and the +-- |T tint is font-coupled so it collapses on a thin bar; a bar's expiry colour is its own fill +-- (Duration Bar Color Mode), leaving Text/Glyph as the bar's alert types. +local function AddExpiryAlertControls(g, parent, proxy, include) + GUI:CreateExpirationControls(g, proxy, { + parent = parent, + anchorOptions = OPTS.ANCHOR_OPTIONS, + include = include, + -- Sub-table colour / alpha writes skip the proxy __newindex, so drive the refresh by + -- hand (S.RefreshPreviewLightweight is assigned by editor open; mirrors the card's RPL). + fullUpdate = function() + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end + RefreshLiveFramesThrottled() + end, + -- A mode change collapses the now-irrelevant rows: LayoutChildren re-evaluates hideOn, + -- RefreshChildStates re-applies the grey, and dfAD_ReflowWidgets slides the sibling + -- groups (Duration Text / Stack Count) up or down to track the new height. + refreshStates = function() + g:LayoutChildren() + g:RefreshChildStates() + if parent.dfAD_ReflowWidgets then parent.dfAD_ReflowWidgets() end + end, + }) + g:RefreshChildStates() -- initial grey (the initial hide rides AddGroup's LayoutChildren) +end +P.AddExpiryAlertControls = AddExpiryAlertControls + +-- Colours-S.page cross-link placed under an AD "Color by Time Remaining" TEXT control, matching +-- the aura pages' duration link (jump + whole-section flash). The duration text's By-Time colour +-- draws from the shared Colours-S.page breakpoints, so the link points there. Fixed-layout note, so +-- size it to the group's inner width up front (the group advances Y by the height we pass). +-- Built once in GUI:CreateColorsPageLink. NOT for the bar FILL colour (fixed ramp, immutable). +local function AddDurationColorsLink(g, parent) + local innerW = math.max(40, (g:GetWidth() or 260) - 2 * (g.padding or 10)) + local note = GUI:CreateColorsPageLink(parent, innerW) + g:AddWidget(note, (note.layoutHeight or 16) + 2) + return note +end +P.AddDurationColorsLink = AddDurationColorsLink + +-- Create a proxy table that maps flat key access to an indicator instance +-- Fallback chain: instance value → global defaults → TYPE_DEFAULTS +local function CreateInstanceProxy(auraName, indicatorID) + -- Pin the pool at creation (B2): proxies are minted per effect card, and + -- the card's tab is the record's pool — a callback firing after a tab + -- switch must keep reading/writing the ORIGINAL pool. + local otherPool = IsOtherTab() + local function pool() return otherPool and GetOtherAuras() or GetSpecAuras() end + -- Resolve current type to expose TYPE_DEFAULTS to GUI:CreateColorPicker's + -- Default button. Type changes rebuild the panel (RefreshPage) which makes + -- a fresh proxy, so stashing at construction time is safe. + local _inst = GetIndicatorByID(auraName, indicatorID, pool()) + local _typeDefaults = _inst and TYPE_DEFAULTS[_inst.type] or nil + return setmetatable({ _skipOverrideIndicators = true, __dfDefaults = _typeDefaults }, { + __index = function(_, k) + local inst = GetIndicatorByID(auraName, indicatorID, pool()) + if inst then + local val = inst[k] + if val ~= nil then return val end + end + -- Fall back to global defaults for applicable keys + local fallback + if inst and inst.type then + local gdMap = GLOBAL_DEFAULT_MAP[inst.type] + if gdMap then + local gdKey = gdMap[k] + if gdKey then + local adDB = GetAuraDesignerDB() + local gd = adDB and adDB.defaults + if gd and gd[gdKey] ~= nil then fallback = gd[gdKey] end + end + end + -- Then fall back to TYPE_DEFAULTS + if fallback == nil then + local defaults = TYPE_DEFAULTS[inst.type] + if defaults then fallback = defaults[k] end + end + end + -- Copy-on-read: if fallback is a table, copy it into the instance + -- so that sub-key mutations (e.g. proxy.color.r = 1) persist + if type(fallback) == "table" and inst then + local copy = {} + for fk, fv in pairs(fallback) do copy[fk] = fv end + inst[k] = copy + return copy + end + return fallback + end, + __newindex = function(_, k, v) + local inst = GetIndicatorByID(auraName, indicatorID, pool()) + if not inst then return end + inst[k] = v + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end + RefreshLiveFramesThrottled() + end, + }) +end +P.CreateInstanceProxy = CreateInstanceProxy + +-- Create a proxy table that maps flat key access to nested aura config +local function CreateProxy(auraName, typeKey) + local defaults = TYPE_DEFAULTS[typeKey] + -- Pin the pool at creation (B2) — see CreateInstanceProxy. + local otherPool = IsOtherTab() + local function pool() return otherPool and GetOtherAuras() or GetSpecAuras() end + -- Expose defaults to GUI:CreateColorPicker so its Default button can resolve + -- AD-specific keys (color/expiringColor/etc.) that aren't in PartyDefaults. + return setmetatable({ _skipOverrideIndicators = true, __dfDefaults = defaults }, { + __index = function(_, k) + local auraCfg = pool()[auraName] + if auraCfg and auraCfg[typeKey] then + local val = auraCfg[typeKey][k] + if val ~= nil then return val end + end + -- Fall back to defaults for missing keys + local fallback = defaults and defaults[k] or nil + -- Copy-on-read: if fallback is a table, copy it into the config + -- so that sub-key mutations (e.g. proxy.color.r = 1) persist + if type(fallback) == "table" then + local typeCfg = EnsureTypeConfig(auraName, typeKey, pool()) + local copy = {} + for fk, fv in pairs(fallback) do copy[fk] = fv end + typeCfg[k] = copy + return copy + end + return fallback + end, + __newindex = function(_, k, v) + local typeCfg = EnsureTypeConfig(auraName, typeKey, pool()) + typeCfg[k] = v + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end + RefreshLiveFramesThrottled() + end, + }) +end +P.CreateProxy = CreateProxy + +-- Create a proxy for the aura-level config (priority, expiring) +local function CreateAuraProxy(auraName) + -- Pin the pool at creation (B2) — see CreateInstanceProxy. + local otherPool = IsOtherTab() + local function pool() return otherPool and GetOtherAuras() or GetSpecAuras() end + return setmetatable({ _skipOverrideIndicators = true }, { + __index = function(_, k) + local auraCfg = pool()[auraName] + if auraCfg then return auraCfg[k] end + return nil + end, + __newindex = function(_, k, v) + local auraCfg = EnsureAuraConfig(auraName, pool()) + auraCfg[k] = v + if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end + RefreshLiveFramesThrottled() + end, + }) +end +P.CreateAuraProxy = CreateAuraProxy + +-- ============================================================ +-- WARNING BADGE +-- Some auras have underlying API limitations that mean multiple +-- spells collapse into a single indicator. Entries in Config.lua +-- with a `warningKey` get a small yellow triangle overlay on their +-- spell icon and collapsible header, with a tooltip explaining why. +-- ============================================================ + +local WARNING_TEXTURE = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning.tga" + +-- Resolve a warningKey to localized tooltip text. Keys are defined +-- here rather than Config.lua so they can go through L[] without +-- load-order concerns. +local function GetWarningText(warningKey) + if not warningKey then return nil end + local L = DF.L or setmetatable({}, { __index = function(_, k) return k end }) + if warningKey == "HolyArmamentsMerge" then + return L["Holy Bulwark and Sacred Weapon share the same aura signature and cannot be tracked separately. Both buffs will trigger this single indicator."] + end + return nil +end + +-- Look up the warningKey for a given aura name in the current spec. +local function GetAuraWarningKey(specKey, auraName) + local specList = DF.AuraDesigner.TrackableAuras and DF.AuraDesigner.TrackableAuras[specKey] + if not specList then return nil end + for _, entry in ipairs(specList) do + if entry.name == auraName then return entry.warningKey end + end + return nil +end +P.GetAuraWarningKey = GetAuraWarningKey + +-- Attach (or refresh) a warning triangle badge on the given region. +-- host: parent Frame the badge is attached to (must be a Frame). +-- warnKey: config warning key; nil hides the badge. +-- opts: optional table with: +-- point -- default "TOPRIGHT" +-- relativeTo -- default host +-- relativePoint -- default "TOPRIGHT" +-- offsetX/Y -- default 3, 3 +-- size -- default 16 +-- color -- { r, g, b } default red { 1.0, 0.25, 0.25 } +local function AttachWarningBadge(host, warnKey, opts) + if not host then return end + local badge = host.dfWarningBadge + if not warnKey then + if badge then badge:Hide() end + return + end + local tooltipText = GetWarningText(warnKey) + if not tooltipText then + if badge then badge:Hide() end + return + end + + if not badge then + badge = CreateFrame("Frame", nil, host) + badge:SetFrameLevel(host:GetFrameLevel() + 5) + local tex = badge:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints(badge) + tex:SetTexture(WARNING_TEXTURE) + badge.texture = tex + badge:SetScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = self.tooltipText or "" }) + end) + badge:SetScript("OnLeave", function() GUI:HideTooltip() end) + host.dfWarningBadge = badge + end + + opts = opts or {} + local size = opts.size or 16 + local color = opts.color or { 1.0, 0.25, 0.25 } + badge:SetSize(size, size) + badge:ClearAllPoints() + badge:SetPoint( + opts.point or "TOPRIGHT", + opts.relativeTo or host, + opts.relativePoint or "TOPRIGHT", + opts.offsetX or 3, + opts.offsetY or 3 + ) + badge.texture:SetVertexColor(color[1], color[2], color[3]) + badge.tooltipText = tooltipText + badge:Show() +end +P.AttachWarningBadge = AttachWarningBadge + +-- Ad-hoc add-by-ID auras (picker "Add" with an ID the SpellDB doesn't know) +-- are stored under the key "#" — the name IS the identity, so the +-- record needs no side table and survives reload/profile export for free. +-- Returns the embedded numeric spell ID, or nil for normal aura names. +local function AdHocSpellID(auraName) + if type(auraName) ~= "string" then return nil end + local id = auraName:match("^#(%d+)$") + return id and tonumber(id) or nil +end + +-- Configured ad-hoc "#" auras are never in the trackable pool, so pool-driven +-- pickers (layout-group "Add aura", frame-effect "Add Trigger") can't offer them. +-- Returns a NEW list = the given trackable list plus one aura-info-shaped entry per +-- configured ad-hoc aura (live-resolved display name, pool-grey accent), sorted by +-- spell ID for a stable order. Never mutates `list` — GetTrackableAuras caches it. +local AD_HOC_COLOR = { 0.62, 0.62, 0.62 } +local function WithConfiguredAdHocAuras(list, spec) + local out = {} + for i, info in ipairs(list) do out[i] = info end + local adHoc = {} + -- Pool-routed: the Other tab's group picker offers the OTHER pool's + -- configured ad-hoc auras (never creates it — read accessor). + for auraName in pairs(CurrentAuraPool(spec)) do + local id = AdHocSpellID(auraName) + if id then tinsert(adHoc, { name = auraName, id = id }) end + end + sort(adHoc, function(a, b) return a.id < b.id end) + for _, e in ipairs(adHoc) do + local display = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(e.id) + tinsert(out, { + name = e.name, + display = display or e.name, + color = AD_HOC_COLOR, + spellID = e.id, + class = "ALL", -- picker grid sections by class; ad-hoc ids aren't class spells + }) + end + return out +end +P.WithConfiguredAdHocAuras = WithConfiguredAdHocAuras + +-- Get spell icon texture for an aura +-- Uses static texture IDs to avoid C_Spell.GetSpellTexture returning +-- the wrong icon when talent choice nodes replace a spell. +local function GetAuraIcon(specKey, auraName) + -- Static icon table — always returns the correct icon regardless of talents + local icons = DF.AuraDesigner.IconTextures + if icons and icons[auraName] then + return icons[auraName] + end + -- Fallback to dynamic API for any aura not in the static table + local spellIDs = DF.AuraDesigner.SpellIDs + local specIDs = spellIDs and specKey and spellIDs[specKey] + local spellID = specIDs and specIDs[auraName] + if not spellID or spellID == 0 then + -- Ad-hoc "#" keys resolve directly to their embedded spell ID + spellID = AdHocSpellID(auraName) + end + if not spellID or spellID == 0 then + -- SpellDB fallback (all-spec support): auras with no curated Config + -- entry resolve by name through the FilterRegistry SpellDB + local R = DF.FilterRegistry + local rec = R and R.GetSpellByName and R:GetSpellByName(auraName) + spellID = rec and rec.id + end + if not spellID or spellID == 0 then return nil end + if C_Spell and C_Spell.GetSpellTexture then + return C_Spell.GetSpellTexture(spellID) + elseif GetSpellTexture then + return GetSpellTexture(spellID) + end + return nil +end +P.GetAuraIcon = GetAuraIcon + +-- (CountActiveEffects removed — uncalled since the v4 flat-card redesign; +-- reclaimed for the file-scope 200-locals ceiling.) + +-- ============================================================ +-- MULTI-TRIGGER HELPERS +-- Functions for managing trigger auras on frame-level effects +-- ============================================================ + +-- Get triggers for a frame effect (returns owning aura name in a table if no explicit triggers) +local function GetFrameEffectTriggers(auraName, typeKey) + local auraCfg = CurrentAuraPool()[auraName] + local typeCfg = auraCfg and auraCfg[typeKey] + if typeCfg and typeCfg.triggers then + return typeCfg.triggers + end + return { auraName } -- Default: just the owning aura +end +P.GetFrameEffectTriggers = GetFrameEffectTriggers + +-- Add a trigger aura to a frame effect. `pool` (optional) pins the target +-- pool — the floating trigger picker captures it at OPEN time so a dropdown +-- surviving a tab switch can't write the trigger into the wrong pool. +local function AddFrameEffectTrigger(auraName, typeKey, triggerName, pool) + local typeCfg = EnsureTypeConfig(auraName, typeKey, pool) + if not typeCfg.triggers then + typeCfg.triggers = { auraName } -- Initialize with owner + end + -- Check not already present + for _, t in ipairs(typeCfg.triggers) do + if t == triggerName then return end + end + tinsert(typeCfg.triggers, triggerName) +end +P.AddFrameEffectTrigger = AddFrameEffectTrigger + +-- Remove a trigger aura from a frame effect (minimum 1 trigger required) +local function RemoveFrameEffectTrigger(auraName, typeKey, triggerName) + local auraCfg = CurrentAuraPool()[auraName] + local typeCfg = auraCfg and auraCfg[typeKey] + if not typeCfg or not typeCfg.triggers or #typeCfg.triggers <= 1 then return end + for i, t in ipairs(typeCfg.triggers) do + if t == triggerName then + tremove(typeCfg.triggers, i) + break + end + end +end +P.RemoveFrameEffectTrigger = RemoveFrameEffectTrigger + +-- ============================================================ +-- SHARED SPELL PICKER STATE +-- Every AD picking context (add indicator, layout-group adds, +-- triggers) opens the shared spell database picker +-- (FilterRegistry/SpellPicker.lua) over the right panel; the +-- open helpers live below the tab system (OpenADPicker). +-- ============================================================ + +-- (S.adPickerHandle declared on the state table) +S.adPickerDirty = false -- an add landed while the picker stayed open + -- (the tab behind it is stale; rebuilt on close) + +-- Close the shared picker if it's open. Called on sub-tab, main-tab and +-- spec switches: the picker's records/handlers capture the pool and effect +-- from open time, so a stale open picker must not linger. +local function CloseADPicker() + if S.adPickerHandle and S.adPickerHandle:IsOpen() then + S.adPickerHandle:Close() + end +end +P.CloseADPicker = CloseADPicker + +-- ============================================================ +-- LAYOUT GROUP HELPERS +-- Functions for managing layout groups +-- ============================================================ + +-- State for expanded layout group cards +local expandedGroups = {} +P.expandedGroups = expandedGroups + +-- expandedGroups key for a layout group id on the ACTIVE tab: raw numeric id +-- on My Buffs (legacy keys, kept as-is), "othergroup:" on Other Buffs. +-- The two id counters overlap, so the string prefix keeps expansion state +-- per-pool ("dgroup:" is the Debuffs form in the same table). +local function GroupExpandKey(groupID) + if IsOtherTab() then return "othergroup:" .. groupID end + return groupID +end +P.GroupExpandKey = GroupExpandKey + +-- Find which layout group (if any) an indicator belongs to — searched in the +-- ACTIVE tab's group store, so an other-pool indicator only matches other-pool +-- groups (a same-named spec-pool member with a matching indicatorID can never +-- false-match across pools). +local function GetIndicatorLayoutGroup(auraName, indicatorID) + local groups = CurrentLayoutGroups() + for _, group in ipairs(groups) do + if group.members then + for _, member in ipairs(group.members) do + if member.auraName == auraName and member.indicatorID == indicatorID then + return group + end + end + end + end + return nil +end +P.GetIndicatorLayoutGroup = GetIndicatorLayoutGroup + +-- (GetUngroupedIndicators removed — uncalled since the group picker moved to +-- the full spell-picker "group" mode; reclaimed for the 200-locals ceiling.) + +-- Create a new layout group. kind: nil/"members" = classic member arranger +-- (legacy records carry no kind); "filter" = a container-backed group linked to +-- registry filters (stable preset keys / custom ids in filterSelection) with +-- uniform per-group styling (iconSize / maxIcons on top of the shared layout). +local function CreateLayoutGroup(name, kind) + local adDB = GetAuraDesignerDB() + if not adDB then return nil end + -- Pool-routed: the Other Buffs tab creates into the flat spec-independent + -- store (born lazily HERE — the first add) with its own id counter. + local groups, id + if IsOtherTab() then + groups = GetOtherLayoutGroups(true) -- the first add creates the store + if not adDB.nextOtherLayoutGroupID then adDB.nextOtherLayoutGroupID = 1 end + id = adDB.nextOtherLayoutGroupID + adDB.nextOtherLayoutGroupID = id + 1 + else + groups = GetSpecLayoutGroups() + if not adDB.nextLayoutGroupID then adDB.nextLayoutGroupID = 1 end + id = adDB.nextLayoutGroupID + adDB.nextLayoutGroupID = id + 1 + end + local group = { + id = id, + name = name or NextGroupName(groups, (kind == "filter") and "Filter Group" or "Group"), + anchor = "TOPLEFT", + offsetX = 0, + offsetY = 0, + growDirection = "RIGHT_DOWN", + iconsPerRow = 8, + spacing = 2, + } + if kind == "filter" then + group.kind = "filter" + group.filterSelection = { presets = {}, customs = {} } + group.iconSize = 24 + -- Filter groups start compact (4×4); member groups keep the shared 8. + -- Creation-time values only — existing saved groups are untouched. + group.iconsPerRow = 4 + group.maxIcons = 4 + else + group.members = {} + end + tinsert(groups, group) + return group +end +P.CreateLayoutGroup = CreateLayoutGroup + +-- Delete a layout group by ID (from the ACTIVE tab's store; the member- +-- indicator cascade removes from the active pool via RemoveIndicatorInstance's +-- CurrentAuraPool routing — members always live in their group's pool) +local function DeleteLayoutGroup(groupID) + local groups = CurrentLayoutGroups() + for i, group in ipairs(groups) do + if group.id == groupID then + -- Delete all member indicators when deleting the group + if group.members then + for _, member in ipairs(group.members) do + RemoveIndicatorInstance(member.auraName, member.indicatorID) + end + end + tremove(groups, i) + break + end + end + expandedGroups[GroupExpandKey(groupID)] = nil +end +P.DeleteLayoutGroup = DeleteLayoutGroup + +-- Delete a debuff category group by ID (C2). No member indicators to cascade +-- (category groups own no placed indicators). The caller runs the FULL +-- structural chain afterwards — the group's claimed categories return to the +-- main debuff bar. Card keys are "dgroup:" (see S.BuildDebuffGroupsTab). +local function DeleteDebuffGroup(groupID) + local groups = DebuffGroupsRead() + for i, group in ipairs(groups) do + if group.id == groupID then + tremove(groups, i) + break + end + end + expandedGroups["dgroup:" .. groupID] = nil +end +P.DeleteDebuffGroup = DeleteDebuffGroup + +-- Find a layout group by ID (active tab's store) +local function GetLayoutGroupByID(groupID) + local groups = CurrentLayoutGroups() + for _, group in ipairs(groups) do + if group.id == groupID then return group end + end + return nil +end +P.GetLayoutGroupByID = GetLayoutGroupByID + +-- Add a member to a layout group +local function AddGroupMember(groupID, auraName, indicatorID) + local group = GetLayoutGroupByID(groupID) + if not group then return end + if not group.members then group.members = {} end + -- Check not already in this group + for _, m in ipairs(group.members) do + if m.auraName == auraName and m.indicatorID == indicatorID then return end + end + tinsert(group.members, { auraName = auraName, indicatorID = indicatorID }) +end +P.AddGroupMember = AddGroupMember + +-- Remove a member from a layout group +local function RemoveGroupMember(groupID, auraName, indicatorID) + local group = GetLayoutGroupByID(groupID) + if not group or not group.members then return end + for i, m in ipairs(group.members) do + if m.auraName == auraName and m.indicatorID == indicatorID then + tremove(group.members, i) + break + end + end +end +P.RemoveGroupMember = RemoveGroupMember + +-- Swap two members in a layout group (for reordering) +local function SwapGroupMembers(groupID, idx1, idx2) + local group = GetLayoutGroupByID(groupID) + if not group or not group.members then return end + if idx1 < 1 or idx1 > #group.members or idx2 < 1 or idx2 > #group.members then return end + group.members[idx1], group.members[idx2] = group.members[idx2], group.members[idx1] +end +P.SwapGroupMembers = SwapGroupMembers + +-- Anchor dot pool (populated during CreateFramePreview, used by drag system) +local anchorDots = {} +P.anchorDots = anchorDots + +-- Anchor point positions relative to the mock frame +local ANCHOR_POSITIONS = { + TOPLEFT = { x = 0, y = 0, ax = "TOPLEFT", ay = "TOPLEFT" }, + TOP = { x = 0.5, y = 0, ax = "TOP", ay = "TOP" }, + TOPRIGHT = { x = 1, y = 0, ax = "TOPRIGHT", ay = "TOPRIGHT" }, + LEFT = { x = 0, y = 0.5, ax = "LEFT", ay = "LEFT" }, + CENTER = { x = 0.5, y = 0.5, ax = "CENTER", ay = "CENTER" }, + RIGHT = { x = 1, y = 0.5, ax = "RIGHT", ay = "RIGHT" }, + BOTTOMLEFT = { x = 0, y = 1, ax = "BOTTOMLEFT", ay = "BOTTOMLEFT" }, + BOTTOM = { x = 0.5, y = 1, ax = "BOTTOM", ay = "BOTTOM" }, + BOTTOMRIGHT = { x = 1, y = 1, ax = "BOTTOMRIGHT", ay = "BOTTOMRIGHT" }, +} +P.ANCHOR_POSITIONS = ANCHOR_POSITIONS + +-- ============================================================ +-- FRAME REFERENCES (populated during build) +-- Declared early so drag/indicator/effects code can capture them +-- ============================================================ +-- (S.mainFrame declared on the state table) +-- (S.leftPanel declared on the state table) +-- (S.rightPanel declared on the state table) +-- (S.enableBanner declared on the state table) +-- (S.framePreview declared on the state table) +-- (S.dragHintText declared on the state table) + +-- Layout anchors — stored during build +-- (S.contentRightInset declared on the state table) +-- (S.origY_framePreview declared on the state table) + +-- (The DF_AURA_DESIGNER_RESET_GLOBAL popup was retired with the editing-banner +-- "Reset to Global" button — the preset dropdown's "Inherit (Global)" entry now +-- clears a layout's Aura Designer preset override.) + +-- ============================================================ +-- UI STATE (v4 redesign — tabbed right panel) +-- ============================================================ +S.activeTab = "effects" -- "effects" | "layout" | "global" +S.activeFilter = "all" -- Filter chip state +local expandedCards = {} -- { ["placed:AuraName#1"] = true, ["frame:border:AuraName"] = true } +P.expandedCards = expandedCards + +-- Tab system frame references +-- (S.tabBar declared on the state table) +local tabButtons = {} -- { effects = btn, layout = btn, global = btn } +P.tabButtons = tabButtons +local mainTabButtons = {} -- { my = btn, other = btn } (B2 main pool tab strip) +P.mainTabButtons = mainTabButtons +-- (S.specDropdown declared on the state table) +-- (S.specDropdownUpdate declared on the state table) +-- (S.tabContentFrame declared on the state table) +-- (S.tabScrollFrame declared on the state table) +local effectCardPool = {} -- Reusable card frames +P.effectCardPool = effectCardPool + +-- ============================================================ +-- EFFECTS LIST DATA COLLECTION +-- Gathers all effects across all auras into a flat list for +-- the new Effects tab. Replaces the old per-aura view. +-- ============================================================ + +local FRAME_LEVEL_TYPE_KEYS = { "border", "healthbar", "background", "nametext", "healthtext", "sound" } + +-- Remove an ad-hoc "#" aura's config entry once it holds no effects at +-- all (empty/absent indicators array AND no frame-level type keys). Ad-hoc +-- auras exist ONLY through their effects — they are never in the trackable +-- pool — so an emptied config would linger in the profile forever as a +-- phantom entry (group/trigger pickers, SavedVariables growth). Curated +-- auras deliberately keep today's linger behavior. Forward-declared above +-- RemoveIndicatorInstance, which calls it after every removal. +S.CleanupAdHocAura = function(auraName) + if not AdHocSpellID(auraName) then return end + local auras = CurrentAuraPool() + local auraCfg = auras and auras[auraName] + if type(auraCfg) ~= "table" then return end + if auraCfg.indicators and #auraCfg.indicators > 0 then return end + for _, typeKey in ipairs(FRAME_LEVEL_TYPE_KEYS) do + if auraCfg[typeKey] ~= nil then return end + end + auras[auraName] = nil +end + +-- Effect-type display labels. Same file-scope-vs-overlay timing issue as the +-- option tables near the top of this file: build them in a registered refresh +-- fn so they pick up the active locale. +S.FRAME_LEVEL_LABELS = {} +S.PLACED_TYPE_LABELS = {} + +local function RefreshEffectLabels() + S.FRAME_LEVEL_LABELS = { + border = L["Border"], + healthbar = L["Health Bar"], + background = L["Background"], + nametext = L["Name Text"], + healthtext = L["Health Text"], + sound = L["Sound Alert"], + } + + S.PLACED_TYPE_LABELS = { + icon = L["Icon"], + square = L["Square"], + bar = L["Bar"], + } +end + +RefreshEffectLabels() +DF:RegisterLocaleRefresh(RefreshEffectLabels) + +local BADGE_COLORS = { + icon = { r = 0.36, g = 0.72, b = 0.94 }, -- Blue + square = { r = 0.51, g = 0.86, b = 0.51 }, -- Green + bar = { r = 0.94, g = 0.71, b = 0.24 }, -- Orange + border = { r = 0.80, g = 0.50, b = 0.80 }, -- Purple + healthbar = { r = 0.94, g = 0.31, b = 0.31 }, -- Red + background = { r = 0.40, g = 0.55, b = 0.65 }, -- Slate + nametext = { r = 0.72, g = 0.72, b = 0.94 }, -- Light blue + healthtext = { r = 0.72, g = 0.72, b = 0.94 }, -- Light blue + sound = { r = 0.94, g = 0.76, b = 0.24 }, -- Gold/yellow +} +P.BADGE_COLORS = BADGE_COLORS + +-- Collect all configured effects into a flat, sorted list +-- Returns: { { source="placed"|"frame", auraName, typeKey, ... }, ... } +local function CollectAllEffects() + local effects = {} + + local spec = ResolveSpec() + local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) + -- Build display name lookup (only auras belonging to current spec) + local displayNames = {} + if trackable then + for _, info in ipairs(trackable) do + displayNames[info.name] = info.display + end + end + + local isOther = IsOtherTab() + for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do + -- My Buffs: only show effects for auras belonging to the current spec. + -- Ad-hoc "#" auras (picker add-by-ID) always belong — they are + -- never in the trackable pool, so resolve their display name live. + -- Other Buffs: the pool is spec-independent — every record shows + -- (names are SpellDB names or ad-hoc keys; resolve display live). + local adHocID = AdHocSpellID(auraName) + local displayName + if isOther then + displayName = OtherPoolDisplayName(auraName) + else + displayName = displayNames[auraName] + if not displayName and adHocID then + displayName = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(adHocID) or auraName + end + end + if type(auraCfg) == "table" and displayName then + -- Placed indicators + if auraCfg.indicators then + for _, indicator in ipairs(auraCfg.indicators) do + tinsert(effects, { + source = "placed", + auraName = auraName, + displayName = displayName, + indicatorID = indicator.id, + typeKey = indicator.type, + config = indicator, + anchor = indicator.anchor or "CENTER", + }) + end + end + + -- Frame-level effects (current per-aura model) + for _, typeKey in ipairs(FRAME_LEVEL_TYPE_KEYS) do + if auraCfg[typeKey] then + tinsert(effects, { + source = "frame", + auraName = auraName, + displayName = displayName, + typeKey = typeKey, + config = auraCfg[typeKey], + }) + end + end + end + end + + -- Sort: newest first (reverse by insertion order — higher IDs first for placed) + sort(effects, function(a, b) + -- Placed before frame-level + if a.source ~= b.source then + return a.source == "placed" + end + -- Within placed: higher indicatorID first (newest) + if a.source == "placed" and b.source == "placed" then + return (a.indicatorID or 0) > (b.indicatorID or 0) + end + -- Within frame-level: alphabetical by type + return a.typeKey < b.typeKey + end) + + return effects +end +P.CollectAllEffects = CollectAllEffects + +-- Check if a specific aura + type combo already has a placed indicator +local function IsAuraTypePlaced(auraName, typeKey) + local auraCfg = CurrentAuraPool()[auraName] + if not auraCfg or not auraCfg.indicators then return false end + for _, indicator in ipairs(auraCfg.indicators) do + if indicator.type == typeKey then return true end + end + return false +end +P.IsAuraTypePlaced = IsAuraTypePlaced + +-- ============================================================ +-- DRAG AND DROP SYSTEM +-- Modeled after DandersCDM's ghost-based drag pattern: +-- Ghost frame (TOOLTIP strata, EnableMouse false) follows cursor +-- Anchor dots act as drop targets via OnEnter/OnLeave +-- OnUpdate frame polls IsMouseButtonDown for drop detection +-- ============================================================ + +local dragState = { + isDragging = false, + auraName = nil, -- Which aura is being dragged + auraInfo = nil, -- Full aura info table + specKey = nil, -- Spec key for icon lookup + dropAnchor = nil, -- Currently hovered anchor name + moveIndicatorID = nil, -- Set when re-dragging an existing placed indicator + indicatorType = nil, -- "icon" | "square" | "bar" — type to create on drop +} +P.dragState = dragState + +S.dragGhost = nil +S.dragUpdateFrame = nil + +local function CreateDragGhost() + if S.dragGhost then return S.dragGhost end + + S.dragGhost = CreateFrame("Frame", "DFAuraDesignerDragGhost", UIParent, "BackdropTemplate") + S.dragGhost:SetSize(36, 36) + S.dragGhost:SetFrameStrata("TOOLTIP") + S.dragGhost:SetFrameLevel(1000) + S.dragGhost:EnableMouse(false) -- KEY: mouse events pass through to drop targets + S.dragGhost:Hide() + + if not S.dragGhost.SetBackdrop then Mixin(S.dragGhost, BackdropTemplateMixin) end + DF.GUI:CreateElementBackdrop(S.dragGhost, { + edgeSize = 2, + bgColor = { 0.05, 0.05, 0.05, 0.9 }, + }) + + -- Spell icon + local icon = S.dragGhost:CreateTexture(nil, "ARTWORK") + icon:SetPoint("TOPLEFT", 3, -3) + icon:SetPoint("BOTTOMRIGHT", -3, 3) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + S.dragGhost.icon = icon + + -- Name label under ghost + local label = S.dragGhost:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("TOP", S.dragGhost, "BOTTOM", 0, -2) + label:SetTextColor(1, 1, 1, 0.8) + S.dragGhost.label = label + + return S.dragGhost +end + +-- (S.EndDrag declared on the state table) + +-- Start a move-drag for an existing placed indicator (the ghost + +-- cursor-following + anchor-dot system; new-placement drags died with the +-- old spell-picker cards -- indicators are placed via the shared picker now). +local function StartMoveDrag(auraName, indicatorID, specKey) + if dragState.isDragging then return end + + dragState.isDragging = true + dragState.auraName = auraName + dragState.moveIndicatorID = indicatorID + dragState.specKey = specKey + dragState.dropAnchor = nil + + -- Build minimal auraInfo for hints + local adDB = GetAuraDesignerDB() + local displayName = auraName + if IsOtherTab() then + -- Other-pool keys are SpellDB names / ad-hoc ids — resolve display live + displayName = OtherPoolDisplayName(auraName) + else + local auraList = Adapter and Adapter:GetTrackableAuras(ResolveSpec()) + if auraList then + for _, info in ipairs(auraList) do + if info.name == auraName then + dragState.auraInfo = info + displayName = info.display or auraName + break + end + end + end + end + + -- Setup ghost + local ghost = CreateDragGhost() + local tc = GetThemeColor() + ghost:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) + + local iconTex = GetAuraIcon(specKey, auraName) + if iconTex then + ghost.icon:SetTexture(iconTex) + else + ghost.icon:SetColorTexture(0.3, 0.3, 0.3, 1) + end + ghost.label:SetText(displayName) + ghost:Show() + + -- Show drag hint + if S.dragHintText then + S.dragHintText:SetText(format(L["Drop on an anchor point to move %s"], displayName)) + S.dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) + end + + -- Show and enlarge all anchor dots + local dc = GetThemeColor() + for _, dotFrame in pairs(anchorDots) do + dotFrame:Show() + dotFrame.dot:SetSize(10, 10) + dotFrame.dot:SetColorTexture(dc.r, dc.g, dc.b, 0.5) + end + + -- Start cursor following + if not S.dragUpdateFrame then + S.dragUpdateFrame = CreateFrame("Frame") + end + S.dragUpdateFrame:SetScript("OnUpdate", function() + if not dragState.isDragging then + S.dragUpdateFrame:Hide() + return + end + + local x, y = GetCursorPosition() + local scale = UIParent:GetEffectiveScale() + local cursorX, cursorY = x / scale, y / scale + + ghost:ClearAllPoints() + ghost:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cursorX + 10, cursorY - 10) + + if not IsMouseButtonDown("LeftButton") then + S.EndDrag() + end + end) + S.dragUpdateFrame:Show() +end + +S.EndDrag = function() + if not dragState.isDragging then return end + + local auraName = dragState.auraName + local dropAnchor = dragState.dropAnchor + local moveID = dragState.moveIndicatorID + local indicatorType = dragState.indicatorType or "icon" + + -- Clear state + dragState.isDragging = false + dragState.auraName = nil + dragState.auraInfo = nil + dragState.specKey = nil + dragState.dropAnchor = nil + dragState.moveIndicatorID = nil + dragState.indicatorType = nil + + -- Hide ghost + if S.dragGhost then S.dragGhost:Hide() end + + -- Stop cursor following + if S.dragUpdateFrame then + S.dragUpdateFrame:Hide() + S.dragUpdateFrame:SetScript("OnUpdate", nil) + end + + -- Clear drag hint + if S.dragHintText then + S.dragHintText:SetText("") + end + + -- Hide anchor dots (only visible during drag) + local dc = GetThemeColor() + for _, dotFrame in pairs(anchorDots) do + dotFrame:Hide() + dotFrame.dot:SetSize(6, 6) + dotFrame.dot:SetColorTexture(dc.r, dc.g, dc.b, 0.3) + end + + -- Process the drop + if auraName and dropAnchor then + if moveID then + -- Move existing indicator to the new anchor + local inst = GetIndicatorByID(auraName, moveID) + if inst then + inst.anchor = dropAnchor + inst.offsetX = 0 + inst.offsetY = 0 + end + else + -- Create a new indicator instance at the dropped anchor + local inst = CreateIndicatorInstance(auraName, indicatorType) + if inst then + inst.anchor = dropAnchor + end + end + + -- Expand the new indicator card in the Effects tab (pool-prefixed key) + local auraCfg = CurrentAuraPool()[auraName] + local lastInst = auraCfg and auraCfg.indicators and auraCfg.indicators[#auraCfg.indicators] + if lastInst then + local cardKey = "placed:" .. PoolKeyPrefix() .. auraName .. "#" .. lastInst.id + expandedCards[cardKey] = true + end + end + + -- Refresh everything + DF:AuraDesigner_RefreshPage() +end + +-- ============================================================ +-- PLACED INDICATORS ON PREVIEW +-- Small icons/squares/bars rendered at anchor positions +-- ============================================================ + +local placedIndicators = {} +P.placedIndicators = placedIndicators + +-- Set by ClearPlacedIndicators, consumed by the S.page's RefreshStates: tab +-- switching re-enters the S.page through GUI RefreshCached -> RefreshStates ONLY +-- (the builder is cache-skipped), and RefreshStates early-outs when the S.page +-- dimensions are unchanged — so a canvas cleared by the OnHide hook stayed +-- blank on every same-size revisit (the first revisit only worked because the +-- dims baseline was captured pre-layout). The flag lets the dimension guard +-- distinguish "nothing changed" from "cleared and awaiting repaint". +S.placedCleared = false + +local function ClearPlacedIndicators() + -- Preview border ANIMATIONS must be stopped explicitly on every frame this + -- pass hides: the drivers are external (Border.lua's shared UIParent-hosted + -- driver ticks secretRect borders EVEN WHILE HIDDEN), so hiding without + -- StopAnimation leaves orphaned ticks — the same mandatory teardown the + -- live container runs (_teardownContainer). Group placeholder slots carry + -- their sample-frame children's borders too. + local B = DF.Border + local function stopAnims(f) + if not B then return end + if f.dfBorder then B:StopAnimation(f.dfBorder) end + local samples = f.sampleFrames + if samples then + for i = 1, #samples do + if samples[i].dfBorder then B:StopAnimation(samples[i].dfBorder) end + end + end + end + for _, ind in ipairs(placedIndicators) do + stopAnims(ind) + ind:Hide() + end + wipe(placedIndicators) + + -- Clean up AD indicator maps on the mockFrame + if S.framePreview and S.framePreview.mockFrame then + local mock = S.framePreview.mockFrame + if mock.dfADPreviewSlots then + for _, rec in pairs(mock.dfADPreviewSlots) do + stopAnims(rec.slot) + rec.slot:Hide() + -- The alert slot carries no border (its style is duration-only), so it has + -- no animation to stop — only the indicator's slot needs stopAnims. + if rec.alertSlot then rec.alertSlot:Hide() end + end + end + mock.dfAD = nil + end + S.placedCleared = true +end +P.ClearPlacedIndicators = ClearPlacedIndicators + +-- ============================================================ +-- NATIVE PREVIEW SLOTS (12.1) +-- Each placed indicator on the canvas is a PREVIEW SLOT: a plain frame +-- styled + painted by the container engine's own styler with the exact +-- config the live factory builds (Factory:BuildPreviewConfig) — the +-- preview IS the live rendering. Slots are pooled per instanceKey and +-- recreated when the structural sig changes (regions are create-only). +-- ============================================================ + +local function RenderPreviewIndicator(mockFrame, spec, auraName, info, indicator, effectiveConfig, instanceKey) + local Factory = DF.AuraDesigner and DF.AuraDesigner.Factory + local AC = DF.AuraContainer + if not (Factory and Factory.BuildPreviewConfig and AC and AC.StylePreviewSlot) then return nil end + + -- The aura's real spell ID lives in the spell-pool config (the trackable- + -- aura info entries don't carry IDs) — this drives the previewed icon and + -- identity, exactly like the live container's filter map. + local specIDs = DF.AuraDesigner.SpellIDs and DF.AuraDesigner.SpellIDs[spec] + local spellID = specIDs and specIDs[auraName] + if type(spellID) == "table" then spellID = spellID[1] end + if not spellID then + -- Ad-hoc "#" keys resolve directly — mirror BuildADIdentityFilters + spellID = AdHocSpellID(auraName) + end + if not spellID then + -- SpellDB fallback (all-spec support) — mirror BuildADIdentityFilters + local R = DF.FilterRegistry + local rec = R and R.GetSpellByName and R:GetSpellByName(auraName) + spellID = rec and rec.id + end + -- Resolve the global defaults with the Factory's OWN resolver so the canvas preview and + -- the live render can never disagree on the fallback chain. (Level/strata come along in + -- the table but the preview ignores them: the canvas is standalone, not layered over a + -- unit frame, so there is nothing for a z-order band to mean there.) + local defs = Factory.ResolveDefaults and Factory.ResolveDefaults(GetAuraDesignerDB()) + local cfg, sig = Factory:BuildPreviewConfig(mockFrame, effectiveConfig, indicator.type or "icon", spellID, defs) + if not (cfg.testEntries and cfg.testEntries[1]) then + -- No resolvable spell ID: synthesize an entry from the configured art so + -- the paint can never fall back to the generic curated pool. + cfg.testEntries = { { name = (info and info.display) or auraName } } + end + do + local e = cfg.testEntries[1] + if not e.icon then e.icon = GetAuraIcon(spec, auraName) end + e.duration = 15 + e.stacks = (indicator.type ~= "bar") and 3 or 0 + end + + local store = mockFrame.dfADPreviewSlots + if not store then store = {}; mockFrame.dfADPreviewSlots = store end + local rec = store[instanceKey] + if rec and rec.sig ~= sig then + -- Abandoned slot: stop its border animation — the external shared + -- driver ticks secretRect borders even while hidden (Border.lua). + if rec.slot.dfBorder and DF.Border then DF.Border:StopAnimation(rec.slot.dfBorder) end + rec.slot:Hide() + if rec.alertSlot then rec.alertSlot:Hide() end + rec = nil + end + if not rec then + rec = { slot = CreateFrame("Frame", nil, mockFrame), sig = sig } + store[instanceKey] = rec + end + local slot = rec.slot + AC.StylePreviewSlot(slot, cfg) + + local lay = cfg.layout or {} + if lay.sizeX then + slot:SetSize(lay.sizeX, lay.sizeY or lay.sizeX) + else + local s = lay.size or 24 + slot:SetSize(s, s) + end + slot:SetScale(lay.scale or 1) + slot:ClearAllPoints() + slot:SetPoint(lay.anchor or "TOPLEFT", mockFrame, lay.anchor or "TOPLEFT", lay.offsetX or 0, lay.offsetY or 0) + slot:SetFrameStrata(mockFrame:GetFrameStrata()) + slot:SetFrameLevel(mockFrame:GetFrameLevel() + 8) + AC.PaintPreviewSlot(slot, cfg, 1) + slot:Show() + + -- Expiry Alert sample: a SECOND PREVIEW SLOT laid over the indicator's, styled and + -- painted by the same StylePreviewSlot/PaintPreviewSlot pipeline as the indicator + -- itself and the group blocks. cfg.alertPreview is a whole slot config from + -- Factory:BuildAlertPreviewConfig, carrying the SAME style table the live companion + -- renders — so the FontString, its font, anchor, offsets, alpha and holder level are + -- all container-engine output here, not decisions made in this file. + -- + -- This used to hand-build a FontString and hand-set its layering. Two separate bugs + -- came out of that in one day: the live companion moved and the canvas could not + -- follow, because the canvas was never DERIVED from it, only numerically matched. The + -- rule is the one that already covers test mode — a preview may differ in DATA, never + -- in RENDERING. + -- + -- nil (hidden) whenever the live companion would also be absent: alertSlotStyle is the + -- single gate for both, so "off", "no formatter API" and "show-when-missing" can no + -- longer mean different things on the canvas than they do live. + local ap = cfg.alertPreview + if ap then + local ah = rec.alertSlot + if not ah then + ah = CreateFrame("Frame", nil, slot) + rec.alertSlot = ah + end + -- Share the indicator's OWN entry, so both slots count down the same aura for the + -- same 15s rather than two entries that happen to agree. cfg.testEntries is + -- rewritten above when no spell ID resolved, hence taking it here and not at build. + ap.testEntries = cfg.testEntries + AC.StylePreviewSlot(ah, ap) + -- SetAllPoints AFTER styling: styleButton_regions sizes the slot from the layout, + -- and the alert must coincide with the indicator's rect exactly, the way the live + -- companion's invisible button does. + ah:ClearAllPoints() + ah:SetAllPoints(slot) + ah:SetFrameStrata(slot:GetFrameStrata()) + ah:SetFrameLevel(slot:GetFrameLevel() + (Factory.ALERT_ROW_LIFT or 13)) + -- Reuse the indicator slot's duration object (PaintPreviewSlot armed it just above) + -- so the reveal reacts to the very timer it sits on, not a second one. + AC.PaintPreviewSlot(ah, ap, 1, slot._dfTestDurObj) + ah:Show() + elseif rec.alertSlot then + rec.alertSlot:Hide() + end + return slot +end + +local function WirePreviewIndicator(slot, capturedAura, capturedID, spec) + slot:EnableMouse(true) + if slot.SetMouseClickEnabled then slot:SetMouseClickEnabled(true) end + slot:SetScript("OnMouseUp", function(_, button) + if dragState.isDragging then return end + if button == "RightButton" then + -- Don't delete grouped indicators (managed by layout group) + if not GetIndicatorLayoutGroup(capturedAura, capturedID) then + RemoveIndicatorInstance(capturedAura, capturedID) + DF:AuraDesigner_RefreshPage() + -- Structural change: same full refresh as the effect-card ✕ — + -- the container must rebuild and the buff-row dedup union + -- shrinks (deleted = no longer tracked). + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end + elseif button == "LeftButton" then + -- Collapse all cards and expand only the clicked one. The preview + -- renders the ACTIVE tab's pool only, so PoolKeyPrefix() at click + -- time matches the slot's pool (B1 key scheme). + local cardKey = "placed:" .. PoolKeyPrefix() .. capturedAura .. "#" .. capturedID + wipe(expandedCards) + expandedCards[cardKey] = true + S.activeTab = "effects" + DF:AuraDesigner_RefreshPage() + end + end) + slot:RegisterForDrag("LeftButton") + slot:SetScript("OnDragStart", function() + -- Don't drag grouped indicators (position managed by layout group) + if GetIndicatorLayoutGroup(capturedAura, capturedID) then return end + StartMoveDrag(capturedAura, capturedID, spec) + end) +end + +-- Representative preview icons per debuff category. Category membership is +-- Blizzard-secret at runtime, so the editor shows iconic stand-ins instead of +-- real members. Entry encoding: NEGATIVE numbers are spell IDs (icon resolved +-- live via C_Spell.GetSpellTexture — all evergreen player abilities, so they +-- stay valid across patches); positive numbers would be literal texture +-- FileDataIDs; strings are texture paths. Never shown as live auras — the +-- placeholder desaturates them (example affordance). +local DEBUFF_CATEGORY_SAMPLES = { + boss = { "Interface\\TargetingFrame\\UI-RaidTargetingIcon_8", -348 }, -- skull marker, Immolate + role = { -6343, -113746 }, -- Thunder Clap, Mystic Touch + priority = { -980, -34914 }, -- Agony, Vampiric Touch + crowdControl = { -118, -6770, -5782 }, -- Polymorph, Sap, Fear + raid = { -589, -1943 }, -- Shadow Word: Pain, Rupture + dispellable = { -339, -51514 }, -- Entangling Roots, Hex + _order = { "boss", "role", "priority", "crowdControl", "raid", "dispellable" }, +} + +-- Example icons for a container-backed group's placeholder block, or nil when +-- the group has nothing to sample (caller falls back to the outline-only +-- style). maxDefault mirrors the DrawGroupPlaceholderSlot default so both +-- compute the same slot count. +-- * Filter groups: sample REAL spells from the linked filters — resolve the +-- selection (same R:ResolveSelection the factory uses; preview-path only, +-- so no cache needed) and take the first N canonical ids in sorted order +-- (deterministic — the preview is stable across refreshes). An empty or +-- dangling selection resolves to kind "all" / an empty map → nil. +-- * Debuff groups (records carry .selection): cycle the selected categories' +-- representative samples across all N slots in _order. +local function GroupSampleIcons(group, maxDefault) + local n = max(1, tonumber(group.maxIcons) or maxDefault) + local icons = {} + if group.selection then + -- Debuff category group: round-robin the selected categories + local cats = {} + for _, key in ipairs(DEBUFF_CATEGORY_SAMPLES._order) do + if group.selection[key] then tinsert(cats, DEBUFF_CATEGORY_SAMPLES[key]) end + end + if #cats == 0 then return nil end + for i = 1, n do + local samples = cats[(i - 1) % #cats + 1] + local entry = samples[(floor((i - 1) / #cats) % #samples) + 1] + if type(entry) == "number" and entry < 0 then + entry = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(-entry)) or 134400 + end + icons[i] = entry + end + return icons + end + if group.kind ~= "filter" then return nil end + local R = DF.FilterRegistry + if not R then return nil end + local res = R:ResolveSelection(group.filterSelection, false) + if res.kind == "all" then return nil end -- empty/dangling selection + -- Canonical ids: variant/alt ids collapse onto their record's id; raw ids + -- from custom filters (no registry record) sample as themselves. + local ids, seen = {}, {} + if res.kind == "include" then + for id in pairs(res.map) do + local rec = R.ByID and R.ByID[id] + local cid = rec and rec.id or id + if not seen[cid] then seen[cid] = true; tinsert(ids, cid) end + end + else -- "exclude" (Uncategorised): complement of the known registry + for _, rec in ipairs(R.Spells) do + if not res.map[rec.id] then tinsert(ids, rec.id) end + end + end + if #ids == 0 then return nil end + sort(ids) + for i = 1, min(n, #ids) do + local rec = R.ByID and R.ByID[ids[i]] + if rec then + local _, icon = R:GetSpellDisplay(rec) + icons[i] = icon + else + icons[i] = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(ids[i])) or 134400 + end + end + return icons +end + +-- Shared labeled-outline placeholder for container-backed groups (filter +-- groups on My Buffs, debuff category groups on Debuffs): their contents are +-- Blizzard-filled at runtime (secret visibility, registry/category-driven), +-- so the editor canvas can't preview real member icons. Draw an outline block +-- at the group's anchor/offset sized to its footprint (iconSize × +-- min(maxIcons, iconsPerRow) columns, wrapped rows), filled with EXAMPLE +-- icons (`icons` array from GroupSampleIcons; nil/empty = outline only) so +-- size/spacing/grow/per-row edits are visible. Pooled per group id in the +-- caller's pool table (separate pools — the two id counters can collide); +-- returns the slot for the placedIndicators list. +local function DrawGroupPlaceholderSlot(mockFrame, pool, group, wrapDefault, maxDefault, icons) + local slot = pool[group.id] + if not slot then + slot = CreateFrame("Frame", nil, mockFrame, "BackdropTemplate") + -- The group-name label rides its own high-level child so it stays + -- legible above the sample FRAMES below (a parent's own regions + -- would draw underneath child frames regardless of layer). + slot.labelHost = CreateFrame("Frame", nil, slot) + slot.labelHost:SetAllPoints(slot) + slot.label = slot.labelHost:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(slot.label, 8, "OUTLINE") + slot.label:SetPoint("CENTER", 0, 0) + pool[group.id] = slot + end + local iconSize = max(8, tonumber(group.iconSize) or 24) + local maxIcons = max(1, tonumber(group.maxIcons) or maxDefault) + local wrap = max(1, tonumber(group.iconsPerRow) or wrapDefault) + local spacing = tonumber(group.spacing) or 2 + local cols = min(maxIcons, wrap) + local rows = floor((maxIcons - 1) / wrap) + 1 + slot:SetSize(max(cols * iconSize + (cols - 1) * spacing, 10), + max(rows * iconSize + (rows - 1) * spacing, 10)) + ApplyBackdrop(slot, {r = 0.91, g = 0.66, b = 0.25, a = 0.10}, + {r = 0.91, g = 0.66, b = 0.25, a = 0.8}) + slot.label:SetText(group.name) + slot.label:SetTextColor(0.91, 0.66, 0.25) + slot.label:SetWidth(slot:GetWidth() - 4) + slot.label:SetMaxLines(2) + -- Pin the block's grow-corner at the group's anchor point so it + -- extends in the grow direction (mirror the container's flow origin). + local p, s = strsplit("_", group.growDirection or "RIGHT_DOWN") + local vert, horiz = "TOP", "LEFT" + for _, d in ipairs({ p, s }) do + if d == "UP" then vert = "BOTTOM" + elseif d == "DOWN" then vert = "TOP" + elseif d == "LEFT" then horiz = "RIGHT" + elseif d == "RIGHT" then horiz = "LEFT" + elseif d == "CENTER" then horiz = "" end + end + local selfPoint = (horiz == "") and vert or (vert .. horiz) + slot:ClearAllPoints() + slot:SetPoint(selfPoint, mockFrame, group.anchor or "TOPLEFT", + group.offsetX or 0, group.offsetY or 0) + slot:SetFrameStrata(mockFrame:GetFrameStrata()) + slot:SetFrameLevel(mockFrame:GetFrameLevel() + 8) + -- Above the sample frames' text holders (slot+1 base + duration/stack + -- holder offsets) and border art, so the group name always reads. + slot.labelHost:SetFrameLevel(slot:GetFrameLevel() + 15) + + -- EXAMPLE ICONS inside the block geometry (nil icons = outline only). + -- Each sample is a pooled child FRAME styled + painted by the factory's + -- own preview pipeline (Factory:BuildGroupPreviewConfig from group.style → + -- AuraContainer.StylePreviewSlot/PaintPreviewSlot — the exact path the + -- placed indicators' canvas preview uses), so the group's Appearance + -- settings (border incl. animation, cooldown swipe, duration text with + -- colour-by-time/hide-above, stack count) render on the samples. Regions + -- are create-only (the live Rebuild rule): when the structural sig moves + -- (duration/stacks/border on-off, format key), drop the pool and + -- re-create — same recreate-on-sig idiom as RenderPreviewIndicator. + -- Fully rebound both directions: a group whose samples empty out + -- (filters unlinked, categories deselected) returns to the bare outline + -- (extras hidden), and vice versa. Icons fill the block's grid from its + -- pinned grow-corner (centered rows when the primary direction is + -- CENTER), so grow/wrap edits read directionally. Desaturation is the + -- "example, not live" affordance; style-less groups render through the + -- same pipeline with the default style (= today's live rendering). + local Factory = DF.AuraDesigner and DF.AuraDesigner.Factory + local AC = DF.AuraContainer + local cfg, sig + if icons and Factory and Factory.BuildGroupPreviewConfig and AC and AC.StylePreviewSlot then + cfg, sig = Factory:BuildGroupPreviewConfig(mockFrame, group) + end + local framePool = slot.sampleFrames + if framePool and slot.sampleSig ~= sig then + -- Structural style change: abandon the old frames (hidden — the + -- RenderPreviewIndicator idiom; regions can't be removed in place). + -- Stop their border animations first: the external shared driver + -- ticks secretRect borders even while hidden (Border.lua), so an + -- abandoned animated sample would tick forever. + for i = 1, #framePool do + local f = framePool[i] + if f.dfBorder and DF.Border then DF.Border:StopAnimation(f.dfBorder) end + f:Hide() + end + framePool = nil + slot.sampleFrames = nil + end + slot.sampleSig = sig + if not framePool then framePool = {}; slot.sampleFrames = framePool end + local count = (cfg and icons) and min(#icons, maxIcons) or 0 + if count > 0 then + -- Curated per-slot entries: the sample's art plus a static duration/ + -- stack sample (the paint staggers the countdown per index and runs + -- it through the group's own formatter — colour-by-time buckets and + -- the hide-above blank band mirror live). + local entries = {} + for i = 1, count do + entries[i] = { icon = icons[i], duration = 15, stacks = 3 } + end + cfg.testEntries = entries + end + local step = iconSize + spacing + local xSign = (horiz == "RIGHT") and -1 or 1 + local ySign = (vert == "TOP") and -1 or 1 + for i = 1, count do + local f = framePool[i] + if not f then + f = CreateFrame("Frame", nil, slot) + framePool[i] = f + end + AC.StylePreviewSlot(f, cfg) -- sizes the frame from cfg.layout.size (= iconSize) + f:ClearAllPoints() + local col = (i - 1) % wrap + local row = floor((i - 1) / wrap) + if horiz == "" then + -- CENTER primary: rows centered on the block's vert edge + local lastRow = floor((count - 1) / wrap) + local iconsInRow = (row == lastRow) and (((count - 1) % wrap) + 1) or wrap + f:SetPoint(vert, slot, vert, + (col - (iconsInRow - 1) / 2) * step, ySign * row * step) + else + local corner = vert .. horiz + f:SetPoint(corner, slot, corner, xSign * col * step, ySign * row * step) + end + AC.PaintPreviewSlot(f, cfg, i) + if f.dfIcon then f.dfIcon:SetDesaturated(true) end -- example affordance + f:Show() + end + for i = count + 1, #framePool do + local f = framePool[i] + -- Same external-driver rule as above; a re-shown extra restyles via + -- StylePreviewSlot → Border:Apply, which restarts its animation. + if f.dfBorder and DF.Border then DF.Border:StopAnimation(f.dfBorder) end + f:Hide() + end + + slot:Show() + return slot +end + +local function RefreshPlacedIndicators() + ClearPlacedIndicators() + S.placedCleared = false -- this pass IS the repaint (after Clear re-armed the flag) + if not S.framePreview then return end + + local mockFrame = S.framePreview.mockFrame + if not mockFrame then return end + + local adDB = GetAuraDesignerDB() + local isOther = IsOtherTab() + local isDebuffs = IsDebuffTab() + local spec = ResolveSpec() + -- Other Buffs and the Debuffs tab are spec-independent: render even with + -- no resolvable spec. + if not spec and not (isOther or isDebuffs) then return end + + local auraList = (not isOther and not isDebuffs) and spec and Adapter and Adapter:GetTrackableAuras(spec) or nil + if not auraList and not (isOther or isDebuffs) then return end + + -- Build lookup + local infoLookup = {} + if auraList then + for _, info in ipairs(auraList) do + infoLookup[info.name] = info + end + end + + -- Build layout group position lookup for preview + -- In preview all indicators are visible, so compute positions for all members. + -- The preview renders the ACTIVE tab's pool, so the group pass reads the + -- active tab's group store (spec-keyed on My Buffs, the flat other store on + -- Other Buffs — read-only, never creates it); Debuffs has neither. Keys carry + -- the B1 pool prefix so they line up with the instanceKeys built below. + local keyPrefix = PoolKeyPrefix() + local groupPositions = {} -- "auraName#indicatorID" → { anchor, offsetX, offsetY } + local specGroups = isDebuffs and EMPTY_POOL or CurrentLayoutGroups() + for _, group in ipairs(specGroups) do + if group.members then + for memberIdx, member in ipairs(group.members) do + local key = keyPrefix .. member.auraName .. "#" .. member.indicatorID + -- Compute position based on group settings + local activeIdx = memberIdx - 1 -- 0-based + -- Need to find the indicator's size to compute step (members + -- live in the same pool as their group — the active pool) + local memberCfg = CurrentAuraPool(spec)[member.auraName] + local indCfg = nil + if memberCfg and memberCfg.indicators then + for _, ind in ipairs(memberCfg.indicators) do + if ind.id == member.indicatorID then + indCfg = ind + break + end + end + end + local size = (indCfg and indCfg.size) or (adDB.defaults and adDB.defaults.iconSize) or 24 + local scale = (indCfg and indCfg.scale) or (adDB.defaults and adDB.defaults.iconScale) or 1.0 + local step = (size * scale) + (group.spacing or 2) + + local growth = group.growDirection or "RIGHT" + local primary, secondary = strsplit("_", growth) + if not secondary then + secondary = (primary == "RIGHT" or primary == "LEFT") and "DOWN" or "RIGHT" + end + local wrap = group.iconsPerRow or 8 + if wrap <= 0 then wrap = 1 end + local totalCount = #group.members + local col = activeIdx % wrap + local row = floor(activeIdx / wrap) + local function gOff(d, s) + if d == "LEFT" then return -s, 0 elseif d == "RIGHT" then return s, 0 + elseif d == "UP" then return 0, s elseif d == "DOWN" then return 0, -s end + return 0, 0 + end + local sX, sY = gOff(secondary, step) + local oX, oY + if primary == "CENTER" then + local iconsInRow = wrap + local lastRow = floor((totalCount - 1) / wrap) + if row == lastRow then + iconsInRow = ((totalCount - 1) % wrap) + 1 + end + local centerOff = -((iconsInRow - 1) * step) / 2 + if sX ~= 0 then + oX = (group.offsetX or 0) + (row * sX) + oY = (group.offsetY or 0) + centerOff + (col * step) + else + oX = (group.offsetX or 0) + centerOff + (col * step) + oY = (group.offsetY or 0) + (row * sY) + end + else + local pX, pY = gOff(primary, step) + oX = (group.offsetX or 0) + (col * pX) + (row * sX) + oY = (group.offsetY or 0) + (col * pY) + (row * sY) + end + groupPositions[key] = { + anchor = group.anchor or "TOPLEFT", + offsetX = oX, + offsetY = oY, + } + end + end + end + + -- FILTER GROUP PLACEHOLDERS (My Buffs / Other Buffs): labeled outline + -- blocks via the shared helper above. Pooled per group id — SEPARATE pool + -- table per tab (the two id counters can collide, mirror the dgroup pool); + -- eye-hidden groups draw nothing. Wrap/max defaults 8 match the classic + -- member-group layout. + do + local fgPoolKey = isOther and "dfADOtherFilterGroupSlots" or "dfADFilterGroupSlots" + local fgPool = mockFrame[fgPoolKey] + if not fgPool then fgPool = {}; mockFrame[fgPoolKey] = fgPool end + for _, group in ipairs(specGroups) do + if group.kind == "filter" and group.enabled ~= false then + tinsert(placedIndicators, + DrawGroupPlaceholderSlot(mockFrame, fgPool, group, 8, 8, + GroupSampleIcons(group, 8))) + end + end + end + + -- DEBUFF GROUP PLACEHOLDERS (C2): Debuffs tab only — the preview renders + -- the ACTIVE tab's surfaces, so these blocks never mix with the buff + -- pools' indicators. Separate pool table (dgroup ids come from their own + -- counter and could collide with filter-group ids). Wrap/max defaults 4 + -- mirror CreateDebuffGroup and the factory's buildFilterGroupLayout(_, 4). + -- Read-only: visiting the tab must not create adDB.debuffGroups. + if isDebuffs then + local dgPool = mockFrame.dfADDebuffGroupSlots + if not dgPool then dgPool = {}; mockFrame.dfADDebuffGroupSlots = dgPool end + for _, group in ipairs(DebuffGroupsRead()) do + if group.enabled ~= false then + tinsert(placedIndicators, + DrawGroupPlaceholderSlot(mockFrame, dgPool, group, 4, 4, + GroupSampleIcons(group, 4))) + end + end + end + + -- Iterate all configured auras, find placed indicator instances. + -- Ad-hoc "#" auras are never in the trackable pool — render them with + -- info=nil (RenderPreviewIndicator resolves their id/icon by pattern). + -- Other-pool records render with info=nil + nil identity spec (SpellDB / + -- ad-hoc resolution, mirroring the factory). Slot keys carry the B1 + -- "other:" prefix so the two pools' slots can't collide in the store. + -- Hidden indicators (eye toggle, enabled == false) don't render — same as live. + -- (keyPrefix hoisted above the group-position pass — same value.) + local idSpec = isOther and nil or spec + for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do + local info = infoLookup[auraName] + if type(auraCfg) == "table" and (isOther or info or AdHocSpellID(auraName)) and auraCfg.indicators then + for _, indicator in ipairs(auraCfg.indicators) do + if indicator.enabled ~= false then + local instanceKey = keyPrefix .. auraName .. "#" .. indicator.id + local capturedAura = auraName + local capturedID = indicator.id + + -- Apply layout group position override if applicable + local effectiveConfig = indicator + local gPos = groupPositions[instanceKey] + if gPos then + effectiveConfig = setmetatable({ + anchor = gPos.anchor, + offsetX = gPos.offsetX, + offsetY = gPos.offsetY, + }, { __index = indicator }) + end + + local slot = RenderPreviewIndicator(mockFrame, idSpec, auraName, info, indicator, effectiveConfig, instanceKey) + if slot then + WirePreviewIndicator(slot, capturedAura, capturedID, idSpec) + tinsert(placedIndicators, slot) + end + end + end + end + end +end +P.RefreshPlacedIndicators = RefreshPlacedIndicators + +-- ============================================================ +-- PREVIEW EFFECTS +-- Apply frame-level effects (border, healthbar, text, alpha) +-- for the currently selected aura on the mock frame +-- ============================================================ + +local function GetOrCreatePreviewCustomBorder(mockFrame, key) + if not mockFrame.dfPreviewCustomBorders then + mockFrame.dfPreviewCustomBorders = {} + end + local pool = mockFrame.dfPreviewCustomBorders + if pool[key] then return pool[key] end + -- Stage 5.4: preview uses DF.Border (mirrors the runtime), below the + -- shared preview border (+5). + pool[key] = DF.Border:New(mockFrame, { frameLevelOffset = 4, layer = "OVERLAY" }) + return pool[key] +end + +local function RefreshPreviewEffects() + if not S.framePreview then return end + local mockFrame = S.framePreview.mockFrame + if not mockFrame then return end + + -- Reset shared border overlay (Stage 5.4: DF.Border — hide edges + anim) + if S.framePreview.borderOverlay then + DF.Border:Apply(S.framePreview.borderOverlay, { enabled = false }) + end + -- Reset custom border overlays + if mockFrame.dfPreviewCustomBorders then + for _, ch in pairs(mockFrame.dfPreviewCustomBorders) do + DF.Border:Apply(ch, { enabled = false }) + end + end + if S.framePreview.healthFill then + S.framePreview.healthFill:SetVertexColor(0.18, 0.80, 0.44, 0.85) + end + if S.framePreview.nameText then + S.framePreview.nameText:SetTextColor(0.18, 0.80, 0.44, 1) + end + if S.framePreview.hpText then + S.framePreview.hpText:SetTextColor(0.87, 0.87, 0.87, 1) + end + mockFrame:SetAlpha(1) + -- Reset the shared single-target elements to their defaults ONCE, before any + -- aura's effects are applied. Previously the background's reset lived in an + -- in-loop `elseif`, so a background-less aura could wipe an earlier aura's + -- background depending on pairs() iteration order — the intermittent + -- "doesn't show on preview" report for Background / Health Bar Color. + if S.framePreview.healthBg then + S.framePreview.healthBg:SetColorTexture(0, 0, 0, 0.4) + end + if S.framePreview.missingHealth then + S.framePreview.missingHealth:SetColorTexture(0, 0, 0, 0.4) + end + + -- Frame-level effects all draw onto the SAME single preview elements (one + -- healthFill / healthBg / nameText / etc.), so when more than one aura + -- configures the same type they conflict. The runtime resolves this by + -- priority (higher number wins; first claim per type — see prioritySort and + -- Indicators:Apply's `if state.X then return end`). Mirror that here so the + -- preview is deterministic instead of pairs()-order-dependent: iterate auras + -- in descending-priority order (tiebreak by name) and apply first-wins per type. + local sortedAuras = {} + for auraName, auraCfg in pairs(CurrentAuraPool()) do + if type(auraCfg) == "table" then -- skip corrupted entries + sortedAuras[#sortedAuras + 1] = { name = auraName, cfg = auraCfg, priority = auraCfg.priority or 5 } + end + end + sort(sortedAuras, function(a, b) + if a.priority ~= b.priority then return a.priority > b.priority end -- higher number = higher priority + return a.name < b.name + end) + + local claimed = {} + for _, entry in ipairs(sortedAuras) do + local auraName, auraCfg = entry.name, entry.cfg + + -- Border effect (Stage 5.4: rendered via DF.Border, mirroring the runtime). + -- Shared borders use a single overlay (first/highest-priority claim wins); + -- custom borders get independent per-aura overlays so multiple can stack. + -- Every type skips hidden blocks (eye toggle, enabled == false) — same as pickWinner. + if auraCfg.border and auraCfg.border.enabled ~= false and auraCfg.border.ShowBorder ~= false then + local spec = DF.Border:BuildSpec(auraCfg.border, "") + spec.animation = nil -- AD border animation retired on 12.1; the preview must match the + -- live render (which strips it at the container chokepoint) so a + -- stuck-on BorderAnimationType from an old profile doesn't animate here. + if not spec.color then spec.color = { r = 1, g = 1, b = 1, a = 1 } end + spec.enabled = true + if auraCfg.border.borderMode == "custom" then + DF.Border:Apply(GetOrCreatePreviewCustomBorder(mockFrame, auraName), spec) + elseif not claimed.border and S.framePreview.borderOverlay then + claimed.border = true + DF.Border:Apply(S.framePreview.borderOverlay, spec) + end + end + + -- Health bar color (first claim wins) + if not claimed.healthbar and auraCfg.healthbar and auraCfg.healthbar.enabled ~= false and S.framePreview.healthFill then + claimed.healthbar = true + local clr = auraCfg.healthbar.color or {r = 1, g = 1, b = 1, a = 1} + local blend = auraCfg.healthbar.blend or 0.5 + if auraCfg.healthbar.mode == "Replace" then + S.framePreview.healthFill:SetVertexColor(clr.r, clr.g, clr.b, clr.a or 1) + else + -- Tint: blend original green with the configured color, scaled by alpha + -- so dragging the colour picker's alpha visibly weakens the tint + -- (matches ApplyHealthBar in Indicators.lua: overlay = blend × alpha). + local effBlend = blend * (clr.a or 1) + local r = 0.18 * (1 - effBlend) + clr.r * effBlend + local g = 0.80 * (1 - effBlend) + clr.g * effBlend + local b = 0.44 * (1 - effBlend) + clr.b * effBlend + S.framePreview.healthFill:SetVertexColor(r, g, b, 1) + -- Tint Entire Bar: paint the same tint hue over the (dark) missing- + -- health region so the preview shows the colour spanning the full bar. + if auraCfg.healthbar.tintWholeBar and S.framePreview.missingHealth then + S.framePreview.missingHealth:SetColorTexture(clr.r * effBlend, clr.g * effBlend, clr.b * effBlend, 0.4 + 0.25 * effBlend) + end + end + end + + -- Background color (first claim wins). Recolours the frame background — shows + -- through the missing-health area, like the runtime overlay behind the bars. + if not claimed.background and auraCfg.background and auraCfg.background.enabled ~= false and S.framePreview.healthBg then + claimed.background = true + local clr = auraCfg.background.color or {r = 1, g = 1, b = 1, a = 1} + if auraCfg.background.mode == "Replace" then + local a = clr.a or 1 + S.framePreview.healthBg:SetColorTexture(clr.r, clr.g, clr.b, 0.4 + 0.6 * a) + else + local blend = (auraCfg.background.blend or 0.5) * (clr.a or 1) + -- Blend the configured colour over the dark default background. + S.framePreview.healthBg:SetColorTexture(clr.r * blend, clr.g * blend, clr.b * blend, 0.4 + 0.4 * blend) + end + end + + -- Name text color (first claim wins) + if not claimed.nametext and auraCfg.nametext and auraCfg.nametext.enabled ~= false and S.framePreview.nameText then + claimed.nametext = true + local clr = auraCfg.nametext.color or {r = 1, g = 1, b = 1, a = 1} + S.framePreview.nameText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) + end + + -- Health text color (first claim wins) + if not claimed.healthtext and auraCfg.healthtext and auraCfg.healthtext.enabled ~= false and S.framePreview.hpText then + claimed.healthtext = true + local clr = auraCfg.healthtext.color or {r = 1, g = 1, b = 1, a = 1} + S.framePreview.hpText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) + end + + end -- for _, entry in sortedAuras +end +P.RefreshPreviewEffects = RefreshPreviewEffects + +-- ============================================================ +-- LIGHTWEIGHT PREVIEW REFRESH +-- Re-applies indicator settings to existing preview frames without +-- destroying/recreating them. Called from proxy __newindex so every +-- slider drag tick, checkbox toggle, or dropdown change is live. +-- ============================================================ + +S.RefreshPreviewLightweight = function() + if not S.framePreview or not S.framePreview.mockFrame then return end + local mockFrame = S.framePreview.mockFrame + + local adDB = GetAuraDesignerDB() + local isOther = IsOtherTab() + local isDebuffs = IsDebuffTab() + local spec = ResolveSpec() + if not spec and not (isOther or isDebuffs) then return end + + -- Build layout group position lookup (same as RefreshPlacedIndicators: + -- active tab's group store over the active pool, pool-prefixed keys; + -- Debuffs has no member groups so the pass reads empty there) + local keyPrefix = PoolKeyPrefix() + local groupPositions = {} + local specGroups2 = isDebuffs and EMPTY_POOL or CurrentLayoutGroups() + for _, group in ipairs(specGroups2) do + if group.members then + for memberIdx, member in ipairs(group.members) do + local key = keyPrefix .. member.auraName .. "#" .. member.indicatorID + local activeIdx = memberIdx - 1 + local memberCfg = CurrentAuraPool(spec)[member.auraName] + local indCfg = nil + if memberCfg and memberCfg.indicators then + for _, ind in ipairs(memberCfg.indicators) do + if ind.id == member.indicatorID then indCfg = ind; break end + end + end + local size = (indCfg and indCfg.size) or (adDB.defaults and adDB.defaults.iconSize) or 24 + local scale = (indCfg and indCfg.scale) or (adDB.defaults and adDB.defaults.iconScale) or 1.0 + local step = (size * scale) + (group.spacing or 2) + -- Grid-aware layout matching RefreshPlacedIndicators / ComputeGroupOffset + local growth = group.growDirection or "RIGHT" + local primary, secondary = strsplit("_", growth) + if not secondary then + secondary = (primary == "RIGHT" or primary == "LEFT") and "DOWN" or "RIGHT" + end + local wrap = group.iconsPerRow or 8 + if wrap <= 0 then wrap = 1 end + local totalCount = #group.members + local col = activeIdx % wrap + local row = floor(activeIdx / wrap) + local function gOff(d, s) + if d == "LEFT" then return -s, 0 elseif d == "RIGHT" then return s, 0 + elseif d == "UP" then return 0, s elseif d == "DOWN" then return 0, -s end + return 0, 0 + end + local sX, sY = gOff(secondary, step) + local oX, oY + if primary == "CENTER" then + local iconsInRow = wrap + local lastRow = floor((totalCount - 1) / wrap) + if row == lastRow then + iconsInRow = ((totalCount - 1) % wrap) + 1 + end + local centerOff = -((iconsInRow - 1) * step) / 2 + if sX ~= 0 then + oX = (group.offsetX or 0) + (row * sX) + oY = (group.offsetY or 0) + centerOff + (col * step) + else + oX = (group.offsetX or 0) + centerOff + (col * step) + oY = (group.offsetY or 0) + (row * sY) + end + else + local pX, pY = gOff(primary, step) + oX = (group.offsetX or 0) + (col * pX) + (row * sX) + oY = (group.offsetY or 0) + (col * pY) + (row * sY) + end + groupPositions[key] = { anchor = group.anchor or "TOPLEFT", offsetX = oX, offsetY = oY } + end + end + end + + -- Re-apply placed indicator instances using current settings + -- (hidden indicators skipped — RenderPreviewIndicator would resurrect their + -- slot; keyPrefix hoisted above the group-position pass — same value) + local idSpec = isOther and nil or spec + for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do + if type(auraCfg) == "table" and auraCfg.indicators then + for _, indicator in ipairs(auraCfg.indicators) do + if indicator.enabled ~= false then + local instanceKey = keyPrefix .. auraName .. "#" .. indicator.id + + -- Apply layout group position override if applicable + local effectiveConfig = indicator + local gPos = groupPositions[instanceKey] + if gPos then + effectiveConfig = setmetatable({ + anchor = gPos.anchor, offsetX = gPos.offsetX, offsetY = gPos.offsetY, + }, { __index = indicator }) + end + + -- Restyle/repaint through the shared preview slot (recreated only + -- when the structural sig changes; cosmetic changes restyle in place). + local infoLW = nil + if not isOther and Adapter and Adapter.GetTrackableAuras then + for _, ti in ipairs(Adapter:GetTrackableAuras(spec)) do + if ti.name == auraName then infoLW = ti; break end + end + end + local slot = RenderPreviewIndicator(mockFrame, idSpec, auraName, infoLW, indicator, effectiveConfig, instanceKey) + if slot then + WirePreviewIndicator(slot, auraName, indicator.id, idSpec) + end + end + end + end + end + + -- Also refresh frame-level preview effects (border, healthbar color, text colors, alpha) + RefreshPreviewEffects() +end \ No newline at end of file diff --git a/AuraDesigner/UI/Indicators.lua b/AuraDesigner/UI/Indicators.lua new file mode 100644 index 00000000..a5e2d980 --- /dev/null +++ b/AuraDesigner/UI/Indicators.lua @@ -0,0 +1,1041 @@ +-- Part 3 of the Aura Designer editor, split from Options.lua. +-- Aliases of objects the first part created; they add no state. +local addonName, DF = ... +local L = DF.L +local GUI = DF.GUI +local Adapter = DF.AuraDesigner.Adapter +local S = DF.AuraDesigner._uiState +local P = DF.AuraDesigner._priv +local C_TEXT_DIM = GUI.Colors.textDim +local OPTS = P.OPTS +local ResolveSpec = P.ResolveSpec +local IsOtherTab = P.IsOtherTab +local CurrentAuraPool = P.CurrentAuraPool +local OtherPoolDisplayName = P.OtherPoolDisplayName +local CopyIndicatorAppearance = P.CopyIndicatorAppearance +local RefreshLiveFramesThrottled = P.RefreshLiveFramesThrottled +local AddExpiryAlertControls = P.AddExpiryAlertControls +local AddDurationColorsLink = P.AddDurationColorsLink +local CreateProxy = P.CreateProxy + +-- ============================================================ +-- INDICATOR TYPE WIDGET BUILDER +-- (Tile strip removed in v4 redesign) +-- ============================================================ + + +-- layoutGroup: optional layout group table; if set, anchor/offset controls are replaced with a note +-- indicatorID: optional indicator ID for placed indicators (used by Copy From) +local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOffset, layoutGroup, indicatorID) + local proxy = optProxy or CreateProxy(auraName, typeKey) + local contentWidth = width or 248 + -- widgets[] entries are {widget, height} so the reflow path can use + -- group.calculatedHeight (current after a LayoutChildren) while + -- non-group widgets fall back to the stored at-build-time height. + local widgets = {} + local startY = 10 + (yOffset or 0) -- top padding + optional offset + local totalHeight = startY + + -- P4.7 (12.1 GUI status overlays): collected here so the block pass at the + -- end of BuildTypeContent can frost the effect-settings groups the factory + -- can't (yet) drive, WITHOUT touching the trigger tags above (built into + -- `parent` before this function runs — the working "which aura" layer). + -- swmCheck is captured for the surgical Show-When-Missing block. See block pass below. + local swmCheck, wholeBarCheck, swmGroup, durColorByTimeCtl + + local function AddWidget(widget, height) + widget:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -totalHeight) + if widget.SetWidth then widget:SetWidth(contentWidth - 10) end + tinsert(widgets, { widget = widget, height = height or 30 }) + totalHeight = totalHeight + (height or 30) + end + + -- Reflow all widgets in this BuildTypeContent's stack. When a group's + -- LayoutChildren updates its own height (e.g. Border's animation + -- widgets show/hide), the siblings below were anchored at FIXED y + -- positions based on the old height — they stay put, causing overlap + -- (group grew) or large gap (group shrank). Walk the list re-anchor + -- each widget at the running total height, reading the current + -- group.calculatedHeight for groups so the new layout flows correctly. + -- The host container's height is updated too so any parent scroll + -- range stays accurate. + parent.dfAD_ReflowWidgets = function() + local y = startY + for _, entry in ipairs(widgets) do + local w = entry.widget + local h + if w.calculatedHeight then + -- SettingsGroup tracks its current height after LayoutChildren. + h = w.calculatedHeight + else + h = entry.height + end + w:ClearAllPoints() + w:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -y) + y = y + h + end + parent:SetHeight(y) + end + + local function AddGroup(header, buildFn, showSummary) + local group = GUI:CreateSettingsGroup(parent, contentWidth - 10, { + collapsible = true, + showSummary = showSummary or false, + }) + group.padding = 10 -- match the main Options groups' inner padding (airier scale) + group:AddWidget(GUI:CreateHeader(parent, header), GUI.RowHeight.sectionHeader) + buildFn(group) + local h = group:LayoutChildren() + AddWidget(group, h) + -- Tag the Show-When-Missing group so the surgical 12.1 block can find it. + if header == L["Show When Missing"] then swmGroup = group end + return group + end + + + -- ── COPY FROM (placed indicators only: icon, square, bar) ── + if indicatorID and (typeKey == "icon" or typeKey == "square" or typeKey == "bar") then + local copyContainer = CreateFrame("Frame", nil, parent) + copyContainer:SetHeight(36) + + local copyLabel = copyContainer:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(copyLabel, 8, "") + copyLabel:SetPoint("TOPLEFT", 1, -1) + copyLabel:SetText(L["COPY APPEARANCE FROM"]) + copyLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local capturedAuraName = auraName + local capturedIndicatorID = indicatorID + local capturedTypeKey = typeKey + + -- Build the list of other placed indicators of the same type as the + -- dropdown's options (rebuilt on each open via optionsFunc so newly-added + -- indicators appear). Keyed by a synthetic id -> a captured source table; + -- picking one copies its appearance. The opener stays "Select indicator..." + -- (it's an action picker, not a value selector), achieved by a customGet + -- that always returns that label string (no matching option key). + local copySources = {} + local function BuildCopyFromOptions() + wipe(copySources) + local isOther = IsOtherTab() + local spec = (not isOther) and ResolveSpec() or nil + local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) + local displayNames = {} + if trackable then + for _, info in ipairs(trackable) do + displayNames[info.name] = info.display + end + end + + -- Sources come from the SAME pool as the destination card (the + -- active tab's) — CopyIndicatorAppearance resolves both ends + -- through the pool-routed GetIndicatorByID. + local sources = {} + for srcAura, auraCfg in pairs(CurrentAuraPool(spec)) do + if type(auraCfg) == "table" and auraCfg.indicators then + for _, ind in ipairs(auraCfg.indicators) do + if ind.type == capturedTypeKey then + -- Skip self + if not (srcAura == capturedAuraName and ind.id == capturedIndicatorID) then + tinsert(sources, { + auraName = srcAura, + displayName = isOther and OtherPoolDisplayName(srcAura) + or displayNames[srcAura] or srcAura, + indicatorID = ind.id, + }) + end + end + end + end + end + sort(sources, function(a, b) return a.displayName < b.displayName end) + + local options = { _order = {} } + for i, src in ipairs(sources) do + local key = "src" .. i + copySources[key] = src + options[key] = { text = src.displayName } + options._order[i] = key + end + return options + end + + local copyDrop = GUI:CreateDropdown( + copyContainer, "", BuildCopyFromOptions(), + nil, nil, nil, + function() return L["Select indicator..."] end, -- customGet (static opener) + function(key) -- customSet (action) + local src = copySources[key] + if src then + CopyIndicatorAppearance(src.auraName, src.indicatorID, capturedAuraName, capturedIndicatorID) + DF:AuraDesigner_RefreshPage() + end + end, + { inline = true, optionsFunc = BuildCopyFromOptions } + ) + copyDrop:SetPoint("TOPLEFT", 0, -12) + copyDrop:SetPoint("RIGHT", copyContainer, "RIGHT", 0, 0) + copyDrop:SetHeight(20) + + AddWidget(copyContainer, 38) + end + + -- Color picker callback shorthand — refreshes both the AD preview and live frames + local function RPL() if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end + + -- Shared Duration Bar section for the icon / square placed indicators — both carry + -- durationBar* keys and the strip hangs off the slot edge regardless of shape. Same + -- control set as the filter-group card and the buff/debuff rows (one shared spec + -- builder, DF:BuildDurationBarSpec, reads these keys). Greys IMPERATIVELY: AD cards + -- have no disableOn/RefreshStates loop (unlike the row pages), so the enable gate + -- AND the curve-mode dimming of Texture/Bar Color are driven by hand from the Enable + -- and Color Mode callbacks. Structural writes (enable/position/height/gap/colorMode) + -- rebuild via the proxy's __newindex → RefreshLiveFramesThrottled, exactly like the + -- Show Duration toggle; only the colour pickers need RPL (sub-table writes skip + -- __newindex — see the Border group's note). + local function AddDurationBarGroup() + AddGroup(L["Duration Bar"], function(g) + local dbWidgets, curveGated = {}, {} + local function UpdateBarGrey() + local on = proxy.durationBarEnabled and true or false + local curve = DF:IsDurationBarCurveMode(proxy.durationBarColorMode) + for i = 1, #dbWidgets do + local w = dbWidgets[i] + local enable = on and not (curveGated[w] and curve) + if w.SetEnabled then w:SetEnabled(enable) + else + w:SetAlpha(enable and 1 or 0.4) + if w.EnableMouse then w:EnableMouse(enable) end + end + end + end + g:AddWidget(GUI:CreateCheckbox(parent, L["Enable Duration Bar"], proxy, "durationBarEnabled", UpdateBarGrey), 28) + local function barChild(widget, h) g:AddWidget(widget, h); dbWidgets[#dbWidgets + 1] = widget; return widget end + barChild(GUI:CreateDropdown(parent, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, proxy, "durationBarPosition"), 54) + barChild(GUI:CreateSlider(parent, L["Height"], 1, 12, 1, proxy, "durationBarHeight"), 54) + barChild(GUI:CreateSlider(parent, L["Gap"], 0, 10, 1, proxy, "durationBarGap"), 54) + barChild(GUI:CreateDropdown(parent, L["Color Mode"], + DF:GetDurationBarColorModes(), + proxy, "durationBarColorMode", UpdateBarGrey), 54) + -- A curve mode brings its own ramp texture and forces a white tint, so these + -- two do nothing while it is selected — grey them (curveGated) when it is. + local texW = barChild(GUI:CreateTextureDropdown(parent, L["Bar Texture"], proxy, "durationBarTexture"), 54) + local colW = barChild(GUI:CreateColorPicker(parent, L["Bar Color"], proxy, "durationBarColor", true, RPL, RPL, true), 28) + curveGated[texW] = true; curveGated[colW] = true + barChild(GUI:CreateColorPicker(parent, L["Background Color"], proxy, "durationBarBGColor", true, RPL, RPL, true), 28) + barChild(GUI:CreateCheckbox(parent, L["Reverse Fill"], proxy, "durationBarReverseFill"), 28) + UpdateBarGrey() + end) + end + + if typeKey == "icon" then + -- Position + AddGroup(L["Position"], function(g) + if layoutGroup then + local groupNote = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + groupNote:SetTextColor(0.91, 0.66, 0.25, 0.8) + groupNote:SetText(format(L["Position managed by: %s"], layoutGroup.name or L["Layout Group"])) + g:AddWidget(groupNote, 18) + else + g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "anchor", function() DF:AuraDesigner_RefreshPage() end), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "offsetX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "offsetY"), 54) + end + end) + -- Appearance + AddGroup(L["Appearance"], function(g) + g:AddWidget(GUI:CreateSlider(parent, L["Size"], 8, 64, 1, proxy, "size"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 3.0, 0.05, proxy, "scale"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Alpha"], 0, 1, 0.05, proxy, "alpha"), 54) + g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], proxy, "hideSwipe"), 28) + -- Text-only mode: the icon TEXTURE is hidden, so a border (static OR + -- expiring) would frame nothing. Rebuild the S.page on toggle so the + -- Border group + the expiring-border thickness control hide/show + -- (gated on proxy.hideIcon below) — matching the runtime, which + -- force-disables both borders in this mode. + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Icon (Text Only)"], proxy, "hideIcon", function() + DF:AuraDesigner_RefreshPage() + end), 28) + end) + -- Show When Missing + AddGroup(L["Show When Missing"], function(g) + local desatCb + local function UpdateDesatState() + if not desatCb then return end + if proxy.showWhenMissing then + desatCb:SetAlpha(1) + desatCb:EnableMouse(true) + else + desatCb:SetAlpha(0.4) + desatCb:EnableMouse(false) + end + end + g:AddWidget(GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() + UpdateDesatState() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end), 28) + desatCb = GUI:CreateCheckbox(parent, L["Desaturate When Missing"], proxy, "missingDesaturate", function() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + g:AddWidget(desatCb, 28) + UpdateDesatState() + end) + -- Border (Stage 5.1c — unified controls via CreateBorderControls). + -- Show / Thickness / Inset are the same widgets as before; the helper + -- adds Style / Texture / Color / Gradient / Shadow / BlendMode / + -- Offset / Alpha on top. Animation, classColor, roleColor, and the + -- colorByTime checkbox are deliberately omitted — animation isn't + -- wired through AD's expiring system yet, class/role don't fit aura + -- indicators (the indicator's job is to show aura state, not unit + -- identity), and AD's Expiring section already covers "colour by + -- time remaining" implicitly through its own colour curve. + -- Text-only mode hides the icon texture, so the whole Border group is + -- hidden (the runtime force-disables the border there too). + if not proxy.hideIcon then + AddGroup(L["Border"], function(g) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + }, + -- IMPORTANT: AD's per-aura proxy only triggers + -- RefreshLiveFramesThrottled + S.RefreshPreviewLightweight + -- on direct key assignment (proxy.X = v) via __newindex. + -- CreateColorPicker and the Border Alpha slider mutate + -- SUB-TABLE fields (proxy.BorderColor.a = v) which reads + -- through __index then writes to the returned table — no + -- __newindex fires, no refresh runs, and both the live + -- frame AND the AD preview window stay on the pre-edit + -- colour until /reload. + -- + -- RPL (defined above in BuildTypeContent) runs both the + -- preview refresh and the throttled live-frame refresh, so + -- colour-picker / alpha-slider / size-drag updates land + -- everywhere consistently within the 100ms debounce. + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + -- refreshStates re-evaluates hideOn on the Border group's + -- widgets and then reflows the sibling groups below in the + -- card body so the Expiring / Duration Text / Stack Count + -- groups slide up or down to track the Border group's new + -- height. Without the reflow, the Border group's internal + -- LayoutChildren updates its own height but the siblings + -- stay at fixed y positions — animation widgets surface + -- and overlap Expiring, or hide and leave a gap above + -- Expiring. dfAD_ReflowWidgets is set on the BuildTypeContent + -- parent and walks the whole widget stack. + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then + parent.dfAD_ReflowWidgets() + end + end, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) + end) + end -- if not proxy.hideIcon (text-only hides the Border group) + -- Expiring (moved up next to Border — the border's expiring colour and + -- the per-icon effects all key off the same threshold, so grouping + -- them adjacent reads more naturally than burying Expiring at the + -- bottom of the panel.) + -- Icon: single Expiring Colour, Whole Alpha Pulse + Bounce effects. + -- Duration Text + AddGroup(L["Duration Text"], function(g) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], proxy, "showDuration"), 28) + -- Icon-sized formats only — Number "14" / Seconds "14s" / Percent "45%"; + -- FULL and the combined "12s (45%)" live on the BAR card, which has the + -- width. Forward-declared UpdateHideAboveState: the dropdown re-greys + -- Hide Above (percent formats can't compose with it), assigned below. + local UpdateHideAboveState + g:AddWidget(GUI:CreateDropdown(parent, L["Duration Format"], { + NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], + _order = { "NUMBER", "SHORT", "PERCENT" }, + }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Duration Font"], proxy, "durationFont"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Duration Scale"], 0.5, 2.0, 0.1, proxy, "durationScale"), 54) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], proxy, "durationOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "durationOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Duration Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "durationAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "durationX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "durationY"), 54) + durColorByTimeCtl = GUI:CreateCheckbox(parent, L["Color by Time Remaining"], proxy, "durationColorByTime") + g:AddWidget(durColorByTimeCtl, 28) + AddDurationColorsLink(g, parent) + g:AddWidget(GUI:CreateColorPicker(parent, L["Duration Text Color"], proxy, "durationColor", true, RPL, RPL, true), 28) + local hideAboveSlider, hideAboveCheck + -- ASSIGNS the forward-declared local from the Duration Format dropdown + -- above (a `local function` here would shadow it and strand the + -- dropdown's callback on nil). Also greys the pair while a + -- percent-family format is picked — Hide Above's seconds threshold + -- can't band a percent-sampled formatter (see GetDurationFormatFields). + UpdateHideAboveState = function() + if not hideAboveSlider then return end + local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) + if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end + if not pctFmt and proxy.durationHideAboveEnabled then + hideAboveSlider:SetAlpha(1) + hideAboveSlider:EnableMouse(true) + else + hideAboveSlider:SetAlpha(0.4) + hideAboveSlider:EnableMouse(false) + end + end + hideAboveCheck = GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", UpdateHideAboveState) + g:AddWidget(hideAboveCheck, 28) + hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold") + g:AddWidget(hideAboveSlider, 54) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent"), 28) + UpdateHideAboveState() + end) + -- Expiration: the frame-anchored Expiry Alert ELEMENT (own section — + -- distinct from the sound "Expire Alert" group on the sound card). + AddGroup(L["Expiration"], function(g) + AddExpiryAlertControls(g, parent, proxy) + end) + -- Stack Count + AddGroup(L["Stack Count"], function(g) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], proxy, "showStacks"), 28) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Stack Font"], proxy, "stackFont"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Stack Scale"], 0.5, 2.0, 0.1, proxy, "stackScale"), 54) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Stack Outline"], proxy, "stackOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "stackOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Stack Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "stackAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "stackX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "stackY"), 54) + g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) + end) + -- Duration Bar (native SetDurationBar strip — shared with the square card) + AddDurationBarGroup() + + elseif typeKey == "square" then + -- Position + AddGroup(L["Position"], function(g) + if layoutGroup then + local groupNote = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + groupNote:SetTextColor(0.91, 0.66, 0.25, 0.8) + groupNote:SetText(format(L["Position managed by: %s"], layoutGroup.name or L["Layout Group"])) + g:AddWidget(groupNote, 18) + else + g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "anchor", function() DF:AuraDesigner_RefreshPage() end), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "offsetX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "offsetY"), 54) + end + end) + -- Appearance + AddGroup(L["Appearance"], function(g) + g:AddWidget(GUI:CreateSlider(parent, L["Size"], 8, 64, 1, proxy, "size"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 3.0, 0.05, proxy, "scale"), 54) + g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) + g:AddWidget(GUI:CreateSlider(parent, L["Alpha"], 0, 1, 0.05, proxy, "alpha"), 54) + g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], proxy, "hideSwipe"), 28) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Icon (Text Only)"], proxy, "hideIcon"), 28) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end), 28) + end) + -- Border (Stage 5.2 — unified controls via CreateBorderControls). + -- Same full toolkit as the icon's base border: Style / Texture / Colour + -- / Gradient / Shadow / Blend / Offset / Alpha + Animation. The + -- square's expiring system tints the FILL (not the border), so the + -- icon's expiring-border overrides are intentionally NOT added here. + AddGroup(L["Border"], function(g) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + }, + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then + parent.dfAD_ReflowWidgets() + end + end, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) + end) + -- Expiring (moved up next to Border — matches the icon indicator's + -- panel ordering. Border colour, fill pulsate, alpha pulse, and bounce + -- all key off the same threshold; grouping them adjacent to Border + -- reads more naturally than burying Expiring at the bottom.) + -- Square: separate fill + border Expiring colours (alpha handle edits the + -- BORDER colour); Fill Pulsate + Whole Alpha Pulse + Bounce effects. + -- Duration Text + AddGroup(L["Duration Text"], function(g) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], proxy, "showDuration"), 28) + -- Icon-sized formats only (see the icon card's Duration Format note). + local UpdateHideAboveState + g:AddWidget(GUI:CreateDropdown(parent, L["Duration Format"], { + NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], + _order = { "NUMBER", "SHORT", "PERCENT" }, + }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Duration Font"], proxy, "durationFont"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Duration Scale"], 0.5, 2.0, 0.1, proxy, "durationScale"), 54) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], proxy, "durationOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "durationOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Duration Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "durationAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "durationX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "durationY"), 54) + durColorByTimeCtl = GUI:CreateCheckbox(parent, L["Color by Time Remaining"], proxy, "durationColorByTime") + g:AddWidget(durColorByTimeCtl, 28) + AddDurationColorsLink(g, parent) + g:AddWidget(GUI:CreateColorPicker(parent, L["Duration Text Color"], proxy, "durationColor", true, RPL, RPL, true), 28) + local hideAboveSlider, hideAboveCheck + -- ASSIGNS the forward-declared local from the Duration Format dropdown + -- above (a `local function` here would shadow it and strand the + -- dropdown's callback on nil). Also greys the pair while a + -- percent-family format is picked — Hide Above's seconds threshold + -- can't band a percent-sampled formatter (see GetDurationFormatFields). + UpdateHideAboveState = function() + if not hideAboveSlider then return end + local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) + if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end + if not pctFmt and proxy.durationHideAboveEnabled then + hideAboveSlider:SetAlpha(1) + hideAboveSlider:EnableMouse(true) + else + hideAboveSlider:SetAlpha(0.4) + hideAboveSlider:EnableMouse(false) + end + end + hideAboveCheck = GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", UpdateHideAboveState) + g:AddWidget(hideAboveCheck, 28) + hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold") + g:AddWidget(hideAboveSlider, 54) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent"), 28) + UpdateHideAboveState() + end) + -- Expiration: the frame-anchored Expiry Alert ELEMENT (own section — + -- distinct from the sound "Expire Alert" group on the sound card). + AddGroup(L["Expiration"], function(g) + AddExpiryAlertControls(g, parent, proxy) + end) + -- Stack Count + AddGroup(L["Stack Count"], function(g) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], proxy, "showStacks"), 28) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Stack Font"], proxy, "stackFont"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Stack Scale"], 0.5, 2.0, 0.1, proxy, "stackScale"), 54) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Stack Outline"], proxy, "stackOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "stackOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Stack Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "stackAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "stackX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "stackY"), 54) + g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) + end) + -- Duration Bar (native SetDurationBar strip — shared with the icon card) + AddDurationBarGroup() + + elseif typeKey == "bar" then + -- Position + AddGroup(L["Position"], function(g) + if layoutGroup then + local groupNote = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + groupNote:SetTextColor(0.91, 0.66, 0.25, 0.8) + groupNote:SetText(format(L["Position managed by: %s"], layoutGroup.name or L["Layout Group"])) + g:AddWidget(groupNote, 18) + else + g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "anchor", function() DF:AuraDesigner_RefreshPage() end), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "offsetX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "offsetY"), 54) + end + end) + -- Size & Orientation + AddGroup(L["Size & Orientation"], function(g) + g:AddWidget(GUI:CreateDropdown(parent, L["Orientation"], OPTS.BAR_ORIENT_OPTIONS, proxy, "orientation", function() + local w = proxy.width + local h = proxy.height + proxy.width = h + proxy.height = w + local mw = proxy.matchFrameWidth + local mh = proxy.matchFrameHeight + proxy.matchFrameWidth = mh + proxy.matchFrameHeight = mw + DF:AuraDesigner_RefreshPage() + end), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Width"], 0, 200, 1, proxy, "width"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Height"], 1, 30, 1, proxy, "height"), 54) + g:AddWidget(GUI:CreateCheckbox(parent, L["Match Frame Width"], proxy, "matchFrameWidth"), 28) + g:AddWidget(GUI:CreateCheckbox(parent, L["Match Frame Height"], proxy, "matchFrameHeight"), 28) + end) + -- Texture & Colors (group captured to scroll to; the Color Mode widget to flash) + local colorModeDrop + local texColorsGroup = AddGroup(L["Texture & Colors"], function(g) + -- Colour Mode: Static uses Bar Texture + Fill Color; a curve (DF / Classic) swaps in a + -- green->red ramp the drain reveals — so the bar reddens as the aura expires — and + -- forces a white tint, so those two grey out (curveGated) while a curve is selected. + local curveGated = {} + local function UpdateColorModeGrey() + local curve = DF:IsDurationBarCurveMode(proxy.barColorMode) + for w in pairs(curveGated) do if w.SetEnabled then w:SetEnabled(not curve) end end + end + colorModeDrop = g:AddWidget(GUI:CreateDropdown(parent, L["Color Mode"], + DF:GetDurationBarColorModes(), proxy, "barColorMode", UpdateColorModeGrey), 54) + local texW = g:AddWidget(GUI:CreateTextureDropdown(parent, L["Bar Texture"], proxy, "texture"), 54) + local colW = g:AddWidget(GUI:CreateColorPicker(parent, L["Fill Color"], proxy, "fillColor", true, RPL, RPL, true), 28) + curveGated[texW] = true; curveGated[colW] = true + g:AddWidget(GUI:CreateColorPicker(parent, L["Background Color"], proxy, "bgColor", true, RPL, RPL, true), 28) + g:AddWidget(GUI:CreateSlider(parent, L["Alpha"], 0, 1, 0.05, proxy, "alpha"), 54) + g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) + UpdateColorModeGrey() -- initial grey for the curve-gated Texture / Fill Color + end) + -- Border (Stage 5.3 — unified controls via CreateBorderControls). + -- Full toolkit (Style / Texture / Colour / Gradient / Shadow / Blend / + -- Inset / Alpha + Animation). No offset (the bar has its own X/Y) and + -- no class/role (it's an aura bar, not unit identity). The bar's + -- expiring tints the FILL via its colour curve, so the icon/square + -- expiring-border overrides are intentionally not added here. + AddGroup(L["Border"], function(g) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, blendMode = true, gradient = true, + shadow = true, alpha = true, + }, + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then + parent.dfAD_ReflowWidgets() + end + end, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) + end) + -- Duration Text + AddGroup(L["Duration Text"], function(g) + g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], proxy, "showDuration"), 28) + -- The BAR is the wide surface, so the whole format family fits here: + -- the icon-sized three plus FULL ("14 Seconds") and the combined + -- Seconds + Percent ("12s (45%)" — 68914 multi-component text, #5). + local UpdateHideAboveState + g:AddWidget(GUI:CreateDropdown(parent, L["Duration Format"], { + NUMBER = L["Number"], SHORT = L["Seconds"], FULL = L["Full"], + PERCENT = L["Percent"], SECONDS_PERCENT = L["Seconds + Percent"], + _order = { "NUMBER", "SHORT", "FULL", "PERCENT", "SECONDS_PERCENT" }, + }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) + g:AddWidget(GUI:CreateFontDropdown(parent, L["Duration Font"], proxy, "durationFont"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Duration Scale"], 0.5, 2.0, 0.1, proxy, "durationScale"), 54) + g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], proxy, "durationOutline"), 54) + g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "durationOutline"), 28) + g:AddWidget(GUI:CreateDropdown(parent, L["Duration Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "durationAnchor"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "durationX"), 54) + g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "durationY"), 54) + durColorByTimeCtl = GUI:CreateCheckbox(parent, L["Color by Time Remaining"], proxy, "durationColorByTime") + g:AddWidget(durColorByTimeCtl, 28) + AddDurationColorsLink(g, parent) + local hideAboveSlider, hideAboveCheck + -- ASSIGNS the forward-declared local from the Duration Format dropdown + -- above (a `local function` here would shadow it and strand the + -- dropdown's callback on nil). Also greys the pair while a + -- percent-family format is picked — Hide Above's seconds threshold + -- can't band a percent-sampled formatter (see GetDurationFormatFields). + UpdateHideAboveState = function() + if not hideAboveSlider then return end + local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) + if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end + if not pctFmt and proxy.durationHideAboveEnabled then + hideAboveSlider:SetAlpha(1) + hideAboveSlider:EnableMouse(true) + else + hideAboveSlider:SetAlpha(0.4) + hideAboveSlider:EnableMouse(false) + end + end + hideAboveCheck = GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", UpdateHideAboveState) + g:AddWidget(hideAboveCheck, 28) + hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold") + g:AddWidget(hideAboveSlider, 54) + g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent"), 28) + UpdateHideAboveState() + end) + -- Expiration: a bar carries its own colour-by-remaining-time (the Duration Bar Color Mode + -- reddens the fill as it drains, secret-safe and full-size), so the |T Border/Tint aren't + -- offered here — only a Text/Glyph alert. A note whose click jump-scrolls the card up to + -- the Color Mode. (A full InfoBanner link-banner can't live in this AD card — html needs + -- a post-SetWidth reflow the card's static-height path disables — so the whole note is the + -- click target, with "Color Mode" tinted to signal it, like the cross-S.page links.) + AddGroup(L["Expiration"], function(g) + -- Note with a jump-link: only "Color Mode" is clickable — clicking scrolls the card + -- up to Texture & Colors and flashes it. GUI:CreateLink = fixed-layout inline links + -- (safe in the reflowing card); GUI:LinkToSetting = scroll + "show me" flash. The |c + -- placeholder only satisfies the markup parser; CreateLink themes the link itself. + local function scrollToTexColors() + if not (S.tabScrollFrame and texColorsGroup and texColorsGroup.GetTop) then return end + local sfTop, gTop = S.tabScrollFrame:GetTop(), texColorsGroup:GetTop() + if not (sfTop and gTop) then return end + local target = S.tabScrollFrame:GetVerticalScroll() + (sfTop - gTop) - 8 + local maxS = S.tabScrollFrame:GetVerticalScrollRange() or 0 + S.tabScrollFrame:SetVerticalScroll(math.max(0, math.min(maxS, target))) + end + local link = format("|cffffffff|HdfADScroll:texcolors|h%s|h|r", L["Color Mode"]) + -- Fixed-layout note: hand it the group's inner width so its wrapped (2-line) height is + -- known before AddWidget — else it falls back to a fixed slot the text overflows. + local innerW = math.max(40, (g:GetWidth() or 260) - 2 * (g.padding or 10)) + local note = GUI:CreateLink(parent, format(L["For expiry colour, set the %s in Texture & Colors."], link), { + width = innerW, + onLinkClick = function() + -- Scroll the section into view, but FLASH the specific Color Mode widget + -- (LinkToSetting flashes target.widget — pass the group instead to flash the + -- whole section). + GUI:LinkToSetting({ widget = colorModeDrop or texColorsGroup, scrollTo = scrollToTexColors, + flash = { border = true } }) -- a single control: fill + outline + end, + }) + g:AddWidget(note, note.layoutHeight or 34) + AddExpiryAlertControls(g, parent, proxy, { border = false, tint = false, match = false }) + end) + + elseif typeKey == "border" then + -- Appearance — full DF.Border toolkit (Stage 5.4). The legacy 5 + -- styles are now combinations: Solid = Style SOLID; Glow = Style + -- TEXTURE + DF Glow; Dashed/Animated = Border Thickness 0 + DF Dash + -- (speed 0 / >0); Corners = Border Thickness 0 + Corners Only. + -- include offset too — this border covers the whole frame, so nudging + -- it can be useful. No class/role (it's an aura indicator). + AddGroup(L["Appearance"], function(g) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + }, + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then parent.dfAD_ReflowWidgets() end + end, + sizeMin = 0, sizeMax = 8, sizeStep = 1, + }) + -- Draw order: lift this border above the frame's own class/role + -- border so it fully covers it (on by default). Off tucks it back + -- underneath the frame border (the pre-5.4 stacking). + -- WIRED 2026-07-25 (was frosted as "engine-managed, not wired yet"). The frame's own + -- class/role border is a DF.Border child at frame+10, and the AD border container was + -- pinned at exactly +10 too -- same level, so which one won came down to creation + -- order. buildBorderConfig now resolves this flag to +11 (above) or +9 (below), and + -- the flag rides the border structSig so toggling it rebuilds at the new offset. + g:AddWidget(GUI:CreateCheckbox(parent, L["Draw above frame border"], proxy, "drawAboveFrameBorder", RPL), 28) + swmCheck = GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + g:AddWidget(swmCheck, 28) + end) + -- Expiring — full parity with icon/square (Stage 5.4): master enable + + -- State Overrides (border thickness / colour / alpha / animation swap) + -- + the existing Pulsate. The Expiring Animation lets the border swap + -- effect below threshold (e.g. solid → marching DF Dash). + -- Bar: single Expiring Colour, thicker max (8), a duration-priority row, + -- and no Icon-Effects (bars don't pulse/bounce the whole icon). + -- The border type draws no fill, so the expiring "tint" overlay has nothing + -- to tint and ApplyBorderToOverlay never calls SetupExpiringTint — hide the + -- otherwise-dead "Show Expiring Tint" / "Tint Color" controls for this type. + + elseif typeKey == "healthbar" then + -- Appearance + AddGroup(L["Appearance"], function(g) + g:AddWidget(GUI:CreateDropdown(parent, L["Mode"], OPTS.HEALTHBAR_MODE_OPTIONS, proxy, "mode", function() + -- Rebuild so the Blend % slider's hideOn re-evaluates and the + -- group's height recomputes for the new visible-widget set. + DF:AuraDesigner_RefreshPage() + end), 54) + g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) + local blendSlider = GUI:CreateSlider(parent, L["Blend %"], 0, 1, 0.05, proxy, "blend") + -- hideOn is re-evaluated on every LayoutChildren pass, so the slider + -- stays correctly hidden in Replace mode across GUI reopens. A manual + -- :Hide() would be clobbered by LayoutChildren's unconditional :Show(). + blendSlider.hideOn = function() return (proxy.mode or "Replace") == "Replace" end + g:AddWidget(blendSlider, 54) + -- Tint-mode only: tint the WHOLE bar (incl. missing health) instead of + -- just the current-health portion. Hidden in Replace mode (there the bar + -- IS the indicator, so this would hide health loss). Same hideOn as Blend. + wholeBarCheck = GUI:CreateCheckbox(parent, L["Tint Entire Bar"], proxy, "tintWholeBar", RPL) + wholeBarCheck.hideOn = function() return (proxy.mode or "Replace") == "Replace" end + g:AddWidget(wholeBarCheck, 28) + swmCheck = GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + g:AddWidget(swmCheck, 28) + end) + + elseif typeKey == "background" then + -- Appearance — mirrors Health Bar Color. A colour overlay over the frame + -- background (visible in the missing-health area). Replace = opaque cover; + -- Tint = blend × colour alpha so the normal background shows through. + AddGroup(L["Appearance"], function(g) + g:AddWidget(GUI:CreateDropdown(parent, L["Mode"], OPTS.HEALTHBAR_MODE_OPTIONS, proxy, "mode", function() + DF:AuraDesigner_RefreshPage() + end), 54) + g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) + local blendSlider = GUI:CreateSlider(parent, L["Blend %"], 0, 1, 0.05, proxy, "blend") + blendSlider.hideOn = function() return (proxy.mode or "Tint") == "Replace" end + g:AddWidget(blendSlider, 54) + swmCheck = GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end) + g:AddWidget(swmCheck, 28) + end) + + elseif typeKey == "nametext" then + -- Appearance + AddGroup(L["Appearance"], function(g) + g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) + end) + + elseif typeKey == "healthtext" then + -- Appearance + AddGroup(L["Appearance"], function(g) + g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) + end) + + + elseif typeKey == "sound" then + -- Enable checkbox + AddGroup(L["Sound Alert"], function(g) + -- Group-only warning banner + do + local topSpacer = CreateFrame("Frame", nil, parent) + topSpacer:SetHeight(4) + g:AddWidget(topSpacer, 4) + + local banner = GUI:CreateInfoBanner(parent, { + tone = "caution", + text = L["Sound alerts only work when you are in a group."], + }) + banner:SetWidth(contentWidth - 10) + g:AddWidget(banner, banner.layoutHeight) + + local spacer = CreateFrame("Frame", nil, parent) + spacer:SetHeight(6) + g:AddWidget(spacer, 6) + end + + -- Other Buffs: the on-apply sound path has no caster filter + -- (AddAuraAppliedSound), so an other-pool alert also fires for the + -- player's OWN cast — and for a spell that My Buffs also tracked, + -- both pools' sounds would fire. Surface it (B1 concern 2). + if IsOtherTab() then + local obBanner = GUI:CreateInfoBanner(parent, { + tone = "info", + text = L["Sound alerts play when anyone gains this buff, including your own casts."], + }) + obBanner:SetWidth(contentWidth - 10) + g:AddWidget(obBanner, obBanner.layoutHeight) + local obSpacer = CreateFrame("Frame", nil, parent) + obSpacer:SetHeight(6) + g:AddWidget(obSpacer, 6) + end + + local soundOn = proxy.enabled ~= false + g:AddWidget(GUI:CreateCheckbox(parent, L["Enable Sound Alert"], proxy, "enabled", function() + -- Stop sound immediately when disabled + if not proxy.enabled and DF.AuraDesigner.SoundEngine then + DF.AuraDesigner.SoundEngine:StopAura(auraName) + end + DF:AuraDesigner_RefreshPage() + end), 28) + + -- The flat sound below is the APPLIED sound (native Added trigger). This toggle + -- silences it independently of the Buff-Dropped / Stack-Gained events below. + if proxy.appliedEnabled == nil then proxy.appliedEnabled = true end + g:AddWidget(GUI:CreateCheckbox(parent, L["Play when the buff is applied"], proxy, "appliedEnabled", function() + DF:AuraDesigner_RefreshPage() + end), 28) + + -- Sound picker (searchable scrollable dropdown) + local soundDD = GUI:CreateSoundDropdown(parent, L["Sound"], proxy, "soundLSMKey", function() + -- Update soundFile path when LSM key changes + local path = DF:GetSoundPath(proxy.soundLSMKey) + if path then + proxy.soundFile = path + end + end) + g:AddWidget(soundDD, 54) + + -- Custom file path (overrides LSM selection) + local soundPathEB = GUI:CreateEditBox(parent, L["Custom Sound Path"], proxy, "soundFile", nil, 280) + g:AddWidget(soundPathEB, 44) + + -- Preview button + local previewBtn = GUI:CreateButton(parent, L["Preview Sound"], 120, 22, function() + local soundFile = DF:GetSoundPath(proxy.soundLSMKey) or proxy.soundFile + if not soundFile or soundFile == "" then + DF:Say(L["No sound file selected. Choose a sound from the dropdown or enter a custom path."]) + return + end + local volume = proxy.volume or 0.8 + if DF.AuraDesigner.SoundEngine then + local willPlay = DF.AuraDesigner.SoundEngine:PlayWithVolume(soundFile, volume) + if not willPlay then + DF:Say(format(L["Sound file could not be played: %s"], tostring(soundFile))) + end + end + end) + g:AddWidget(previewBtn, 28) + + -- Volume slider + local volumeSlider = GUI:CreateSlider(parent, L["Volume"], 0, 1, 0.05, proxy, "volume") + g:AddWidget(volumeSlider, 54) + + -- Grey out sound sub-controls when sound alert is disabled + if not soundOn then + soundDD:SetAlpha(0.4) + soundDD:EnableMouse(false) + soundPathEB:SetAlpha(0.4) + soundPathEB:EnableMouse(false) + previewBtn:SetAlpha(0.4) + previewBtn:EnableMouse(false) + volumeSlider:SetAlpha(0.4) + volumeSlider:EnableMouse(false) + end + end) + + -- Buff-Dropped + Stack-Gained sounds (native Removed / ApplicationsIncreased + -- triggers, 12.1). Each has its OWN sound, independently enabled. Distinct from the + -- blocked Missing Trigger (fires-while-absent) and Expire Alert (near-expiry + -- threshold), which still need sealed presence / remaining-time reads. On a pre- + -- rename client the triggers are absent — the group shows a note and the sound no-ops. + local newSoundTriggers = (C_UnitAuras and C_UnitAuras.AddAuraSound + and Enum and Enum.UnitAuraSoundTrigger) and true or false + local function AddEventSoundGroup(header, subKey, enableLabel) + AddGroup(header, function(g) + proxy[subKey] = proxy[subKey] or {} + local ec = proxy[subKey] + g:AddWidget(GUI:CreateCheckbox(parent, enableLabel, ec, "enabled", function() + DF:AuraDesigner_RefreshPage() + end), 28) + local dd = GUI:CreateSoundDropdown(parent, L["Sound"], ec, "soundLSMKey", function() + local path = DF:GetSoundPath(ec.soundLSMKey) + if path then ec.soundFile = path end + end) + g:AddWidget(dd, 54) + local prev = GUI:CreateButton(parent, L["Preview Sound"], 120, 22, function() + local sf = DF:GetSoundPath(ec.soundLSMKey) or ec.soundFile + if not sf or sf == "" then + DF:Say(L["No sound file selected. Choose a sound from the dropdown or enter a custom path."]) + return + end + if DF.AuraDesigner.SoundEngine then + DF.AuraDesigner.SoundEngine:PlayWithVolume(sf, proxy.volume or 0.8) + end + end) + g:AddWidget(prev, 28) + if not newSoundTriggers then + g:AddWidget(GUI:CreateNote(parent, + L["Needs the current game build — these sound triggers aren't available on this client yet."], + { width = contentWidth - 20 }), 40) + end + if not (ec.enabled == true) then + dd:SetAlpha(0.4); dd:EnableMouse(false) + prev:SetAlpha(0.4); prev:EnableMouse(false) + end + end) + end + AddEventSoundGroup(L["Buff Dropped"], "dropped", L["Enable Buff-Dropped Sound"]) + AddEventSoundGroup(L["Stack Gained"], "stackGained", L["Enable Stack-Gained Sound"]) + + + end + + -- ============================================================ + -- 12.1 AURA-SYSTEM STATUS OVERLAYS (P4.7 GUI pass) + -- Frost the AD effect-settings the 12.1 Blizzard aura system can't drive. + -- Every block gates on DF:FactoryOwnsAD(d). Only + -- the settings GROUPS built above are targeted — the trigger tags (the + -- working "which aura" layer) live in `parent` above `startY` and are left + -- alone. "roadmap" = temporary (delete the single call site when the port + -- lands); "limitation" = permanent (secret-value casualty). + -- ============================================================ + + if typeKey == "icon" or typeKey == "square" then + -- P4.3/P4.5 SHIPPED: icon/square render on the container engine (native icon / solid + -- fill + cooldown + stacks + border + position), AND Show When Missing now renders via + -- the read-free missing-mode container (static spell icon / colour square while absent). + -- The whole-type roadmap and the Show-When-Missing roadmap overlays are gone. Two + -- surgical blocks remain: + -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, + -- unreadable on the container path, so it is gone rather than frosted. The + -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). + -- * Min Stacks to Show — REMOVED 2026-07-25 (was a permanent limitation, then a + -- confirmed no-op). Stack counts render on the native no-formatter path (a + -- formatter would receive the SECRET application count and trap — see + -- Features/Auras.lua's stacks-formatter warning), so a custom minimum other than + -- the native "shown at >1" is not expressible. The control, its key and its + -- defaults are gone rather than frosted. + -- * Duration "Colour by Time" — P4.4 SHIPPED: the duration text now colours by + -- time via the native bucket formatter (C-side |c escapes), so the roadmap + -- overlay is gone and the control is fully editable under the factory. + elseif typeKey == "bar" then + -- P4.4 SHIPPED: the bar renders on the container engine (native SetDurationBar fill + -- + texture/colour/orientation + border + duration text). The whole-type roadmap + -- overlay is gone. One surgical block remains: + -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, + -- unreadable on the container path, so it is gone rather than frosted. The + -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). + elseif typeKey == "sound" then + -- P4.5 SHIPPED: the sound indicator plays natively via C_UnitAuras.AddAuraSound. + -- The Sound Alert group (picker / volume / preview) and the per-event groups + -- (Applied / Buff Dropped / Stack Gained) are all live and unblocked. + -- REMOVED 2026-07-25 -- Missing Trigger and Expire Alert. "Alert WHILE the buff is + -- absent" is presence-driven and no native hook fires during absence; "alert as the + -- buff FADES" is remaining-time-driven. Both were permanently frosted, and Expire + -- Alert's intent is now served natively by Buff Dropped (the Removed trigger), so + -- the groups and their ten inert keys are gone rather than left as dead controls. + elseif typeKey == "nametext" or typeKey == "healthtext" then + -- RECOVERED (colour-by-cover): the base Color works — the Text Designer keeps a + -- glyph-identical coloured cover in sync with the real element (Render mirrors) + -- and the aura slot's secret visibility shows it on presence. Two surgical blocks: + -- * Show When Missing is not built for text covers at all - the checkbox is + -- simply never created for them. A cover is SetAllPoints onto the real + -- FontString, so its screen rect belongs to the text it mirrors; missing mode + -- hides by MOVING a badge, so parking/pushing could never change what shows. + -- (Same control SHIPPED for border/healthbar/background, whose art really does + -- live inside the window.) Candidate path if it is ever wanted: + -- SetAlphaFromBoolean(presence, 0, 255) on the mirror - on SimpleRegion as well + -- as SimpleFrame, AllowedWhenTainted, and taking both alphas makes inversion + -- free. Open question is sourcing `presence` as a SECRET BOOLEAN we can pass + -- through without reading: an unrun in-game probe. + -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, + -- unreadable on the container path, so it is gone rather than frosted. The + -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). + elseif typeKey == "healthbar" or typeKey == "background" or typeKey == "border" then + -- Base effect settings (colour / mode / style) work on the container + -- engine. Two surgical blocks on top: + -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, + -- unreadable on the container path, so it is gone rather than frosted. The + -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). + -- * Show When Missing — P4.5 SHIPPED: the effect now renders via the read-free + -- missing-mode container (tint / ring shown while the buff is absent), so the + -- roadmap overlay is gone and the checkbox is fully editable under the factory. + -- * Tint Entire Bar (healthbar only) — NO LONGER blocked. The filled health-mirror + -- bar makes the current-health-fill variant (tintWholeBar=false) expressible + -- read-free, so both settings work under the factory. + -- * Gradient border style -- UNFROSTED 2026-07-25 to test the claim behind it. + -- The frost said gradient "needs a resolved rect to compute its direction+ + -- extent" and so degrades to solid on a secret-anchored slot. Re-reading + -- Border.lua that looks wrong on two counts: the gradient path measures + -- NOTHING (it is SetColorTexture + SetGradient on SetPoint-anchored edges -- + -- no GetWidth/GetHeight anywhere in Apply), and Apply's gradient branch is + -- gated on `style == "GRADIENT" and gradient and CreateColor` with no + -- _solidOnly check, so the slot's solidOnly flag never blocked the paint. + -- solidOnly is about secret COLOURS (CreateColor taints on them), which the + -- gradient pickers are not -- they are static config. secretRect is the flag + -- that handles rects, and only TEXTURE style needs it. If gradient still + -- renders solid in game, the cause is something not yet found and the block + -- should come back with the real reason recorded. + end + + totalHeight = totalHeight + 8 -- bottom padding + parent:SetHeight(totalHeight) + return widgets, totalHeight +end \ No newline at end of file diff --git a/AuraDesigner/UI/Options.lua b/AuraDesigner/UI/Options.lua index cc1690db..0b1b2d6b 100644 --- a/AuraDesigner/UI/Options.lua +++ b/AuraDesigner/UI/Options.lua @@ -1,31 +1,31 @@ -local addonName, DF = ... - --- ============================================================ --- AURA DESIGNER - OPTIONS GUI --- Custom S.page layout: left content area + fixed 280px right panel --- Called from Options/Options.lua via DF.BuildAuraDesignerPage() --- ============================================================ - -local pairs, ipairs, type = pairs, ipairs, type -local format = string.format -local wipe = wipe -local tinsert = table.insert -local tremove = table.remove -local max, min, floor = math.max, math.min, math.floor -local strsplit = strsplit -local sort = table.sort -local RAID_CLASS_COLORS = RAID_CLASS_COLORS -local LOCALIZED_CLASS_NAMES_MALE = LOCALIZED_CLASS_NAMES_MALE -local L = DF.L - --- Mutable editor state. --- These were file-scope locals. A `local` re-declared on the far side of a --- file split becomes a SECOND variable, so the halves silently stop sharing --- state -- no error, just a tab that will not switch or a preview that will --- not refresh. One table keeps a split honest: each part reads the same --- state via `local S = DF.AuraDesigner._uiState`. --- GUI and Adapter are NOT here: both are load-time constants (DF.GUI and --- DF.AuraDesigner.Adapter), so each part can simply re-declare them. +local addonName, DF = ... + +-- ============================================================ +-- AURA DESIGNER - OPTIONS GUI +-- Custom S.page layout: left content area + fixed 280px right panel +-- Called from Options/Options.lua via DF.BuildAuraDesignerPage() +-- ============================================================ + +local pairs, ipairs, type = pairs, ipairs, type +local format = string.format +local wipe = wipe +local tinsert = table.insert +local tremove = table.remove +local max, min, floor = math.max, math.min, math.floor +local strsplit = strsplit +local sort = table.sort +local RAID_CLASS_COLORS = RAID_CLASS_COLORS +local LOCALIZED_CLASS_NAMES_MALE = LOCALIZED_CLASS_NAMES_MALE +local L = DF.L + +-- Mutable editor state. +-- These were file-scope locals. A `local` re-declared on the far side of a +-- file split becomes a SECOND variable, so the halves silently stop sharing +-- state -- no error, just a tab that will not switch or a preview that will +-- not refresh. One table keeps a split honest: each part reads the same +-- state via `local S = DF.AuraDesigner._uiState`. +-- GUI and Adapter are NOT here: both are load-time constants (DF.GUI and +-- DF.AuraDesigner.Adapter), so each part can simply re-declare them. -- Shared private helpers. -- A `local function` is invisible to any other file, so a helper called far -- from its declaration pins this file together -- it cannot be split while a @@ -35,9063 +35,1704 @@ local L = DF.L -- changes and behaviour is identical. -- Private by convention -- nothing outside AuraDesigner/ should touch this. local P = {} -local S = {} -DF.AuraDesigner = DF.AuraDesigner or {} +local S = {} +DF.AuraDesigner = DF.AuraDesigner or {} DF.AuraDesigner._uiState = S -DF.AuraDesigner._priv = P - --- Load-time constants. Both used to be assigned inside BuildAuraDesignerPage, --- which made them mutable and so un-splittable. Neither ever actually varies: --- every caller arrives as DF.GUI -> SetupGUIPages -> here, and Adapter was only --- ever assigned DF.AuraDesigner.Adapter. Resolving them at load lets each split --- part re-declare them as plain aliases -- which is why 412 of this file's --- reference sites needed no change at all. --- Both source files load earlier in the TOC, so these are populated here. -local GUI = DF.GUI -local Adapter = DF.AuraDesigner.Adapter --- (S.page and S.db are set per build on the state table) - --- State -S.selectedSpec = nil -- Current spec key being viewed - --- Reusable color constants: reference the shared GUI palette (same numeric --- values, zero visual change) so they track any future palette change in --- lockstep. GUI.lua loads before this file (see .toc), so DF.GUI.Colors is --- populated at parse time. -local C_BACKGROUND = DF.GUI.Colors.background --- (C_PANEL removed — unreferenced; reclaimed for the 200-locals ceiling.) -local C_ELEMENT = DF.GUI.Colors.element -local C_BORDER = DF.GUI.Colors.border -local C_HOVER = DF.GUI.Colors.hover -local C_TEXT = DF.GUI.Colors.text -local C_TEXT_DIM = DF.GUI.Colors.textDim - --- Indicator type definitions --- These option/label tables read L["..."]; at file scope that returns the enUS --- baseline (the languageOverride overlay runs later, at ADDON_LOADED). Build --- them in a registered refresh fn so Core rebuilds them after the overlay — --- otherwise these dropdowns stay English. Value-keys (CENTER, RIGHT, …) and --- the _order arrays are raw identifiers and must NOT be localized. --- One namespace table instead of one local per option table (the main chunk --- rides the Lua 5.1 200-locals ceiling; seven locals reclaimed to one). -local OPTS = {} -P.OPTS = OPTS - -local function RefreshLocaleStrings() - OPTS.INDICATOR_TYPES = { - { key = "icon", label = L["Icon"], placed = true }, - { key = "square", label = L["Square"], placed = true }, - { key = "bar", label = L["Bar"], placed = true }, - { key = "border", label = L["Border"], placed = false }, - { key = "healthbar", label = L["Health Bar Color"], placed = false }, - { key = "background", label = L["Background Color"], placed = false }, - { key = "nametext", label = L["Name Text Color"], placed = false }, - { key = "healthtext", label = L["Health Text Color"], placed = false }, - -- (framealpha removed 2026-07-25 — 12.1 casualty, see the Factory's CASUALTIES note. - -- Whole-frame alpha needs frame:SetAlpha gated on SECRET aura presence, and it also - -- fights the range / out-of-range alpha owners for the same property. It never had a - -- render path on the container engine — only the editor canvas applied it — so the - -- effect showed in the preview and did nothing in game.) - { key = "sound", label = L["Sound Alert"], placed = false }, - } - - OPTS.ANCHOR_OPTIONS = { - CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], - TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], - _order = {"TOPLEFT", "TOP", "TOPRIGHT", "LEFT", "CENTER", "RIGHT", "BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT"}, - } - - OPTS.GROWTH_OPTIONS = { - RIGHT = L["Right"], LEFT = L["Left"], UP = L["Up"], DOWN = L["Down"], - _order = {"RIGHT", "LEFT", "UP", "DOWN"}, - } - - OPTS.BORDER_STYLE_OPTIONS = { - SOLID = L["Solid Border"], ANIMATED = L["Animated Border"], DASHED = L["Dashed Border"], - GLOW = L["Glow"], CORNERS = L["Corners Only"], - _order = {"SOLID", "ANIMATED", "DASHED", "GLOW", "CORNERS"}, - } - - OPTS.HEALTHBAR_MODE_OPTIONS = { - Replace = L["Replace"], Tint = L["Tint"], - _order = {"Replace", "Tint"}, - } - - OPTS.BAR_ORIENT_OPTIONS = { - HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"], - _order = {"HORIZONTAL", "VERTICAL"}, - } - - -- Per-group Sort Order (Wave 2) — mirrors the aura rows' dropdown exactly. - OPTS.SORT_OPTIONS = { - DEFAULT = L["Default (Slot Order)"], TIME = L["Time Remaining"], NAME = L["Alphabetical"], - APPLIED = L["Order Applied"], - _order = {"DEFAULT", "TIME", "NAME", "APPLIED"}, - } -end - -RefreshLocaleStrings() -DF:RegisterLocaleRefresh(RefreshLocaleStrings) - --- ============================================================ --- HELPERS --- ============================================================ - -local function MigrateToSpecScoped(adDB) - if not adDB then return end - - -- V1: migrate flat adDB.auras → spec-keyed adDB.auras - if not adDB._specScopedV1 then - if adDB.auras then - local isFlat = false - for _, val in pairs(adDB.auras) do - -- `border` counts too: an entry that only ever had its border - -- customised carries neither priority nor indicators, and reading it - -- as already-spec-scoped stamped _specScopedV1 without migrating — - -- those auras then rendered nothing, permanently. Core.lua's - -- equivalent probe has always included it; this copy had drifted. - if type(val) == "table" - and (val.priority ~= nil or val.indicators ~= nil or val.border ~= nil) then - isFlat = true - break - end - end - if isFlat then - local oldAuras = adDB.auras - local newAuras = {} - local auraToSpecs = {} - -- Build the name→specs map from the FULL trackable set, not the - -- curated TrackableAuras table alone: the real set is curated PLUS - -- the SpellDB class pool (Adapter:GetTrackableAuras). Using only the - -- curated half meant every pool-sourced aura the user had configured - -- matched nothing — and the wholesale `adDB.auras = newAuras` below - -- then deleted it, with no read-back and no log. - local AD = DF.AuraDesigner - local adapter = AD and AD.Adapter - if adapter and adapter.GetTrackableAuras and AD.SpecInfo then - for specKey in pairs(AD.SpecInfo) do - local ok, list = pcall(adapter.GetTrackableAuras, adapter, specKey) - if ok and type(list) == "table" then - for _, info in ipairs(list) do - if info.name then - if not auraToSpecs[info.name] then auraToSpecs[info.name] = {} end - tinsert(auraToSpecs[info.name], specKey) - end - end - end - end - end - -- Curated table as a fallback/supplement (also covers the case where - -- the adapter or the registry is not available at migration time). - local trackable = AD and AD.TrackableAuras - if trackable then - for specKey, auraList in pairs(trackable) do - for _, info in ipairs(auraList) do - local seen = auraToSpecs[info.name] - if not seen then - auraToSpecs[info.name] = { specKey } - else - local dup = false - for _, s in ipairs(seen) do - if s == specKey then dup = true; break end - end - if not dup then tinsert(seen, specKey) end - end - end - end - end - - local orphans, orphanCount = nil, 0 - for auraName, auraCfg in pairs(oldAuras) do - local specs = auraToSpecs[auraName] - if specs then - for _, specKey in ipairs(specs) do - if not newAuras[specKey] then newAuras[specKey] = {} end - newAuras[specKey][auraName] = DF:DeepCopy(auraCfg) - end - else - -- NEVER drop it. Park unmatched configs where they can be - -- recovered rather than replacing the table out from under - -- them (a SpellDB regen that renames a spell would otherwise - -- destroy that aura's config on the next load). - orphans = orphans or {} - orphans[auraName] = DF:DeepCopy(auraCfg) - orphanCount = orphanCount + 1 - end - end - if orphans then - adDB._unscopedAuras = orphans - if DF.DebugWarn then - DF:DebugWarn("AD", - "spec-scope migration: %d aura config(s) matched no spec; parked in _unscopedAuras", - orphanCount) - end - end - adDB.auras = newAuras - end - end - adDB._specScopedV1 = true - end - - -- V2: migrate flat adDB.layoutGroups array → spec-keyed - if not adDB._specScopedV2 then - if adDB.layoutGroups then - -- Detect flat array: first entry has numeric key and .id field - local isFlat = false - for k, v in pairs(adDB.layoutGroups) do - if type(k) == "number" and type(v) == "table" and v.id ~= nil then - isFlat = true - break - end - end - if isFlat then - local oldGroups = adDB.layoutGroups - local newGroups = {} - -- For each group, find which specs its member auras belong to - local auraToSpecs = {} - if adDB.auras then - for specKey, specAuras in pairs(adDB.auras) do - if type(specAuras) == "table" then - for auraName in pairs(specAuras) do - if not auraToSpecs[auraName] then auraToSpecs[auraName] = {} end - auraToSpecs[auraName][specKey] = true - end - end - end - end - for _, group in ipairs(oldGroups) do - -- Determine which specs this group's members belong to - local targetSpecs = {} - if group.members then - for _, member in ipairs(group.members) do - local specs = auraToSpecs[member.auraName] - if specs then - for specKey in pairs(specs) do - targetSpecs[specKey] = true - end - end - end - end - -- Copy group to each relevant spec, filtering members - for specKey in pairs(targetSpecs) do - if not newGroups[specKey] then newGroups[specKey] = {} end - local groupCopy = DF:DeepCopy(group) - -- Filter members to only those that exist in this spec - if groupCopy.members then - local filtered = {} - for _, member in ipairs(groupCopy.members) do - local specs = auraToSpecs[member.auraName] - if specs and specs[specKey] then - tinsert(filtered, member) - end - end - groupCopy.members = filtered - end - tinsert(newGroups[specKey], groupCopy) - end - end - adDB.layoutGroups = newGroups - end - end - -- Migrate nextLayoutGroupID to per-spec too (just keep global as fallback) - adDB._specScopedV2 = true - end -end - --- Expose for Engine.lua and post-import use -DF.MigrateAuraDesignerSpecScope = MigrateToSpecScoped - --- ============================================================ --- STAGE 5.1b — ICON BORDER KEY MIGRATION --- Renames the legacy per-aura icon border keys to the canonical --- CreateBorderControls naming (matches every other unified-border --- consumer in the addon): --- borderEnabled → ShowBorder --- borderThickness → BorderSize --- borderInset → BorderInset (case-only rename) --- --- Walks every aura × every storage shape (typeKey-keyed sub-config --- and the newer indicators[] array) for every spec. Idempotent — --- only renames when the new key is nil, so a partially-migrated --- config is safe. --- --- Scope: icon (Stage 5.1) + square (Stage 5.2). Bar (Stage 5.3) still --- reuses some of the same legacy key names (borderThickness, borderInset), --- so we stay type-scoped — only rename an instance's keys once that --- indicator type has migrated to the unified backend. The icon and square --- differ only in the enable key: icon used `borderEnabled`, square used --- `showBorder`; both fold to canonical `ShowBorder`. --- ============================================================ - --- Backfill canonical ONLY when absent, but ALWAYS strip the legacy key — even --- when the canonical key already exists. A block edited in the new GUI writes --- canonical keys but leaves the legacy ones behind; the old "swap-and-nil only --- if canonical == nil" form then skipped the strip, leaving the legacy key to --- linger forever (the half-migrated state). Mirrors renameBorderTypeKeys. -local function renameIconBorderKeys(t) - if type(t) ~= "table" then return end - if t.borderEnabled ~= nil then - if t.ShowBorder == nil then t.ShowBorder = t.borderEnabled end - t.borderEnabled = nil - end - if t.borderThickness ~= nil then - if t.BorderSize == nil then t.BorderSize = t.borderThickness end - t.borderThickness = nil - end - if t.borderInset ~= nil then - if t.BorderInset == nil then t.BorderInset = t.borderInset end - t.borderInset = nil - end -end - --- Square (Stage 5.2): border-key renames ONLY. The square's enable key is --- `showBorder` (vs the icon's `borderEnabled`). Deliberately does NOT touch --- `expiringPulsate` — on the square that's the FILL pulse, a different effect --- from the icon's border DF_PULSATE, so it stays a boolean. -local function renameSquareBorderKeys(t) - if type(t) ~= "table" then return end - -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. - if t.showBorder ~= nil then - if t.ShowBorder == nil then t.ShowBorder = t.showBorder end - t.showBorder = nil - end - if t.borderThickness ~= nil then - if t.BorderSize == nil then t.BorderSize = t.borderThickness end - t.borderThickness = nil - end - if t.borderInset ~= nil then - if t.BorderInset == nil then t.BorderInset = t.borderInset end - t.borderInset = nil - end -end - --- Bar (Stage 5.3): border-key renames. Enable key is `showBorder` (like the --- square); the bar also carries a static `borderColor` table → canonical --- `BorderColor`. The bar has no legacy inset key. -local function renameBarBorderKeys(t) - if type(t) ~= "table" then return end - -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. - if t.showBorder ~= nil then - if t.ShowBorder == nil then t.ShowBorder = t.showBorder end - t.showBorder = nil - end - if t.borderThickness ~= nil then - if t.BorderSize == nil then t.BorderSize = t.borderThickness end - t.borderThickness = nil - end - if t.borderColor ~= nil then - if t.BorderColor == nil then t.BorderColor = t.borderColor end - t.borderColor = nil - end -end - --- Border-type indicator (Stage 5.4): its legacy `style` enum maps onto a --- DF.Border style + animation combo. One-way, lossy (the 5 styles become --- canonical Style/Animation combinations). Gated on `style` being present so --- it runs once. -local function renameBorderTypeKeys(t) - if type(t) ~= "table" then return end - -- The legacy `style` key is what forces the old render path (Indicators.lua's - -- `config.style and BuildBorderTypeSpec(...)`), so its presence is what we key - -- on. Crucially we run EVEN WHEN BorderStyle already exists: a block edited in - -- the new GUI writes canonical keys but leaves `style` behind, so it must still - -- get stripped or it keeps rendering via the legacy builder (the half-migrated - -- bug — legacy color/thickness shadowing the GUI's BorderColor/BorderSize). - if t.style == nil then return end - local thickness = t.thickness or 2 - local inset = t.inset or 0 - local color = t.color or { r = 0, g = 0, b = 0, a = 1 } - local legacy = { Solid = "SOLID", Glow = "GLOW", Pulse = "SOLID" } - local style = legacy[t.style] or t.style or "SOLID" - -- Fold legacy → canonical ONLY where the canonical key is absent, so we never - -- overwrite edits the user already made in the new GUI (BorderColor/BorderSize). - if t.ShowBorder == nil then t.ShowBorder = true end - if t.BorderInset == nil then t.BorderInset = inset end - if t.BorderColor == nil then t.BorderColor = color end - if t.BorderStyle == nil then - if style == "GLOW" then - t.BorderStyle = "TEXTURE" - if t.BorderTexture == nil then t.BorderTexture = "DF Glow" end - if t.BorderSize == nil then t.BorderSize = thickness end - elseif style == "DASHED" or style == "ANIMATED" then - -- The legacy dashed/animated styles drew a size-0 border made visible - -- ONLY by a container border animation, which 12.1 retired (aura buttons - -- go forbidden while auras are secret). Fold them to a visible static - -- border so an upgraded config renders something rather than nothing. - t.BorderStyle = "SOLID" - if t.BorderSize == nil then t.BorderSize = thickness end - elseif style == "CORNERS" then - t.BorderStyle = "SOLID" - if t.BorderSize == nil then t.BorderSize = 0 end - if t.BorderAnimationType == nil then - t.BorderAnimationType = "CORNERS_ONLY" - t.BorderAnimationThickness = thickness - t.BorderAnimationColor = color - end - else -- SOLID (and anything unknown) - t.BorderStyle = "SOLID" - if t.BorderSize == nil then t.BorderSize = thickness end - end - elseif t.BorderSize == nil then - -- BorderStyle already set (by the GUI); just backfill size from legacy. - t.BorderSize = thickness - end - -- Strip legacy keys so rendering routes through DF.Border:BuildSpec. - t.style = nil; t.thickness = nil; t.inset = nil; t.color = nil -end - -local function MigrateIconBorderKeysOnAuras(specAuras) - if type(specAuras) ~= "table" then return end - for _, auraCfg in pairs(specAuras) do - if type(auraCfg) == "table" then - -- Old shape: auraCfg. sub-config. - if auraCfg.icon then renameIconBorderKeys(auraCfg.icon) end - if auraCfg.square then renameSquareBorderKeys(auraCfg.square) end - if auraCfg.bar then renameBarBorderKeys(auraCfg.bar) end - if auraCfg.border then renameBorderTypeKeys(auraCfg.border) end - -- New shape: auraCfg.indicators[i] — each instance carries its - -- own border keys when the user has overridden defaults. - if auraCfg.indicators then - for _, ind in ipairs(auraCfg.indicators) do - if ind.type == "icon" then - renameIconBorderKeys(ind) - elseif ind.type == "square" then - renameSquareBorderKeys(ind) - elseif ind.type == "bar" then - renameBarBorderKeys(ind) - elseif ind.type == "border" then - renameBorderTypeKeys(ind) - end - end - end - end - end -end - --- Run the icon/square/bar/border key renames over one `.auras` table, handling --- both the flat ({ auraName → auraCfg }) and spec-scoped --- ({ specKey → { auraName → auraCfg } }) shapes. Mirrors --- MigrateAuraDesignerToInstances' shape detection so we stay correct whether the --- spec-scope migration ran before us or not. -local function MigrateBorderKeysOnAurasTable(auras) - if type(auras) ~= "table" then return end - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - MigrateIconBorderKeysOnAuras(auras) -- flat - else - for _, specAuras in pairs(auras) do -- spec-scoped - MigrateIconBorderKeysOnAuras(specAuras) - end - end - end - break -- Only check first entry for shape detection - end -end - -local function MigrateAuraDesignerIconBorderKeys(modeDb) - local adDB = modeDb and modeDb.auraDesigner - if adDB and adDB.auras then - MigrateBorderKeysOnAurasTable(adDB.auras) - end -end - --- The Designer Presets rework relocated AD aura configs into --- profile.auraDesignerPresets[name].auras. MigrateAuraDesignerIconBorderKeys --- only walks the legacy per-mode modeDb.auraDesigner location, so preset-nested --- border blocks were never folded — they kept their legacy `style` (rendering via --- the old builder) while the GUI wrote canonical keys onto them (the half-migrated --- state). Walk every preset's auras so those blocks get folded + `style` stripped. -local function MigrateAuraDesignerPresetBorderKeys(profile) - local presets = profile and profile.auraDesignerPresets - if type(presets) ~= "table" then return end - for _, preset in pairs(presets) do - if type(preset) == "table" then - MigrateBorderKeysOnAurasTable(preset.auras) - end - end -end - -DF.MigrateAuraDesignerIconBorderKeys = MigrateAuraDesignerIconBorderKeys -DF.MigrateAuraDesignerPresetBorderKeys = MigrateAuraDesignerPresetBorderKeys - --- Lazy, flag-gated border-key fold. This is the one that actually matters for --- live frames: the ADDON_LOADED passes above clean the STORED tables (modeDb --- auraDesigner + presets), but render resolves its config through --- DF:ResolveAuraDesigner / GetModeBaseAuraDesigner, which can hand back a --- resolved / auto-layout-overlay table the load-time passes never touched. --- Mirroring MigrateToSpecScoped's lazy-on-access pattern, we fold the border keys --- on the EXACT adDB about to be rendered/edited, gated by `_borderKeysFoldedV1` --- so it runs once per table. (Idempotent if the table is rebuilt each access.) -local function MigrateBorderKeysLazy(adDB) - if type(adDB) ~= "table" or adDB._borderKeysFoldedV1 then return end - if adDB.auras then MigrateBorderKeysOnAurasTable(adDB.auras) end - adDB._borderKeysFoldedV1 = true -end -DF.MigrateAuraDesignerBorderKeysLazy = MigrateBorderKeysLazy - --- Same lazy-on-access pattern for the type-keyed → instances[] migration. It --- only ran at ADDON_LOADED on modeDb.auraDesigner, so an imported preset still in --- the legacy type-keyed shape (icon/square/bar sub-tables, no `indicators`) would --- render NOTHING — the engine reads `auraCfg.indicators`. Reuses the existing, --- idempotent DF.MigrateAuraDesignerToInstances (guarded per-aura by `not --- auraCfg.indicators`) via a thin modeDb wrapper, so there's no duplicated --- conversion logic. The resolved adDB IS the preset by reference, so folding it --- also cleans the stored data. Runs before the border fold to match load order. -local function MigrateInstancesLazy(adDB) - if type(adDB) ~= "table" or adDB._instancesFoldedV1 then return end - if DF.MigrateAuraDesignerToInstances and adDB.auras then - DF.MigrateAuraDesignerToInstances({ auraDesigner = adDB }) - end - adDB._instancesFoldedV1 = true -end -DF.MigrateAuraDesignerInstancesLazy = MigrateInstancesLazy - --- Lazy, flag-gated Priority-scale flip (old lower-wins → new higher-wins). Same --- resolve-time, once-per-table pattern as the border-key fold above: it runs on --- the EXACT adDB about to be rendered/edited, so auto-layout overlays, presets, --- and the legacy inline config are all covered AT POINT OF USE, gated by --- `_priorityHigherWinsV1`. Fresh configs are born flagged (NewAuraDesignerConfig) --- and exports/imports carry the flag on the copied table, so only genuine pre-4.6 --- data is ever flipped — and exactly once. Replaces the old one-shot ADDON_LOADED --- walker, which misclassified flat auras with no indicators and double-flipped --- newly-created / imported profiles into corruption. -local function FlipAuraPriority(auraCfg) - if type(auraCfg) == "table" and type(auraCfg.priority) == "number" then - local p = math.floor(auraCfg.priority + 0.5) - if p < 1 then p = 1 elseif p > 10 then p = 10 end - auraCfg.priority = 11 - p - end -end -local function MigratePrioritiesLazy(adDB) - if type(adDB) ~= "table" or adDB._priorityHigherWinsV1 then return end - local auras = adDB.auras - if type(auras) == "table" then - -- Shape detection off the first entry only, matching MigrateBorderKeysOnAurasTable. - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - for _, auraCfg in pairs(auras) do FlipAuraPriority(auraCfg) end -- flat: auras[name] - else - for _, specAuras in pairs(auras) do -- spec: auras[spec][name] - if type(specAuras) == "table" then - for _, auraCfg in pairs(specAuras) do FlipAuraPriority(auraCfg) end - end - end - end - end - break - end - end - adDB._priorityHigherWinsV1 = true -end -DF.MigrateAuraDesignerPrioritiesLazy = MigratePrioritiesLazy - --- Per-indicator Frame Level became an ABSOLUTE offset from the unit frame (the render used --- to add 40 to whatever was stored). Shifts every stored value by that same 40 so no --- indicator moves. Runs on the RESOLVED adDB -- presets, auto-layout overlays and the legacy --- inline config are all covered at point of use -- mirroring MigratePrioritiesLazy. -local function ShiftIndicatorLevels(auraCfg) - if type(auraCfg) ~= "table" then return end - local inds = auraCfg.indicators - if type(inds) ~= "table" then return end - for _, ind in pairs(inds) do - if type(ind) == "table" and tonumber(ind.frameLevel) then - ind.frameLevel = tonumber(ind.frameLevel) + 40 - end - end -end -local function MigrateAbsoluteLevelsLazy(adDB) - if type(adDB) ~= "table" or adDB._absoluteFrameLevelV1 then return end - local auras = adDB.auras - if type(auras) == "table" then - -- Shape detection off the first entry only, matching MigratePrioritiesLazy. - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - for _, auraCfg in pairs(auras) do ShiftIndicatorLevels(auraCfg) end - else - for _, specAuras in pairs(auras) do - if type(specAuras) == "table" then - for _, auraCfg in pairs(specAuras) do ShiftIndicatorLevels(auraCfg) end - end - end - end - end - break - end - end - adDB._absoluteFrameLevelV1 = true -end - --- V2 — undo the two places V1 got AD's level wrong. Read this before touching either. --- --- AD's real level has always been 40 (the buff-icon band). Commit 47075137 then added --- `indicatorFrameLevel = 30` as a Config default while the render still computed --- `40 + stored`, so 30 was written into a field that meant a NUDGE — it reads like the --- status-icon convention (30) copied into the wrong units. Effect: 40 + 30 = 70. --- --- V1 above then made levels absolute and added the base back, so a spurious nudge of 30 --- became a stored 70. Correct logic, bad input: it preserved the mistake permanently, and --- 70 sits ABOVE the defensive icon (65) — which is meant to be the top layer. --- --- V1 also only walked auraCfg.indicators. It never shifted adDB.defaults.indicatorFrameLevel, --- so that key was left holding the pre-absolute 30 while every per-indicator value moved to --- the absolute scale. The two numbers have been in different units ever since, which is why --- the Default Frame Level slider reads 30 on a profile whose indicators all render at 70. --- --- So V2 restores the baseline: --- defaults.indicatorFrameLevel == 30 -> 40 --- per-indicator frameLevel == 70 -> 40 --- --- ⚠ Both rewrites are value-targeted, not blanket. 30 and 70 are the exact fingerprints of --- the bad seed. A deliberate absolute 30 is not a thing anyone would choose (it puts AD --- UNDER the buff/debuff rows at 40), and 70 is only reachable from the seeded nudge. The --- accepted cost: someone who genuinely nudged +30 for a reason loses it and lands at the --- baseline. That is judged the right trade while 12.1 is unshipped and the alpha population --- is small — an untouched profile drawing AD over the defensive icon is the worse default. --- Any OTHER stored level is left exactly as it is; those are real user choices. -local function ResetSeededIndicatorLevels(auraCfg) - if type(auraCfg) ~= "table" then return end - local inds = auraCfg.indicators - if type(inds) ~= "table" then return end - for _, ind in pairs(inds) do - if type(ind) == "table" and tonumber(ind.frameLevel) == 70 then - ind.frameLevel = 40 - end - end -end -local function MigrateAbsoluteLevelsV2Lazy(adDB) - if type(adDB) ~= "table" or adDB._absoluteFrameLevelV2 then return end - - local defs = adDB.defaults - if type(defs) == "table" and tonumber(defs.indicatorFrameLevel) == 30 then - defs.indicatorFrameLevel = 40 - end - - local auras = adDB.auras - if type(auras) == "table" then - -- Same shape detection as V1 (flat auraCfg vs spec-scoped), for the same reason. - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - for _, auraCfg in pairs(auras) do ResetSeededIndicatorLevels(auraCfg) end - else - for _, specAuras in pairs(auras) do - if type(specAuras) == "table" then - for _, auraCfg in pairs(specAuras) do ResetSeededIndicatorLevels(auraCfg) end - end - end - end - end - break - end - end - - adDB._absoluteFrameLevelV2 = true -end - -DF.MigrateAuraDesignerAbsoluteLevelsLazy = MigrateAbsoluteLevelsLazy -DF.MigrateAuraDesignerAbsoluteLevelsV2Lazy = MigrateAbsoluteLevelsV2Lazy - - --- Lazy, flag-gated ONE-TIME refresh of the AD global text defaults to the Midnight --- baseline: DF Roboto SemiBold + drop shadow, 1.2 duration scale, stack count seated --- bottom-right (2,-2). Pre-12.1 the AD shipped Friz Quadrata / plain OUTLINE / 1.0 / --- centred stacks — WoW-generic values nobody picks deliberately (same reasoning as the --- settings-panel font flip, Core.lua's _settingsFontRobotoDefaultV1). Runs on the --- RESOLVED adDB (presets, auto-layout overlays, and the legacy inline config all --- covered at point of use), flipping ONLY where the value is still the EXACT old --- default — so any explicit choice sticks, and placed indicators that already store --- their own values are untouched. --- durationColorByTime is DELIBERATELY not flipped: existing profiles keep their --- duration colours (a stored OFF is as likely a choice as a leftover). New / reset --- profiles pick up the new ON default via the Config factory, newly PLACED --- indicators always stamp ON (EnsureTypeConfig, v4 parity), and the Factory render --- resolves nil-instance indicators through adDB.defaults (resolveDefCBT) so the --- stored value actually governs. --- MUST be wired into BOTH this editor accessor AND the Factory render runner --- (Factory.lua ~2270), or the editor shows the new values while live frames render the --- old ones until a manual re-entry forces a rebuild. -local function MigrateDefaultRefreshLazy(adDB) - if type(adDB) ~= "table" or adDB._adDefaultRefreshV1 then return end - local d = adDB.defaults - if type(d) == "table" then - if d.durationFont == "Friz Quadrata TT" then d.durationFont = "DF Roboto SemiBold" end - if d.stackFont == "Friz Quadrata TT" then d.stackFont = "DF Roboto SemiBold" end - if d.durationOutline == "OUTLINE" then d.durationOutline = "SHADOW;OUTLINE" end - if d.stackOutline == "OUTLINE" then d.stackOutline = "SHADOW;OUTLINE" end - if d.durationScale == 1 then d.durationScale = 1.2 end -- 1.0 == 1 in Lua - if d.stackX == 0 then d.stackX = 2 end - if d.stackY == 0 then d.stackY = -2 end - end - adDB._adDefaultRefreshV1 = true -end -DF.MigrateAuraDesignerDefaultRefreshLazy = MigrateDefaultRefreshLazy - -local function GetAuraDesignerDB() - -- The editor is mode-tabbed: it edits the preset the active mode uses - -- (party → its assigned preset, etc.). Because edited == used, live - -- frames stay in sync with the editor automatically. - -- Base variant: the editor edits your base raid preset, not the active runtime - -- auto-layout's overlay (it edits the layout only while IN edit-auto-layout). - local adDB - if DF.GetModeBaseAuraDesigner then - local mode = (GUI and GUI.SelectedMode) or "party" - adDB = DF:GetModeBaseAuraDesigner(mode) - end - -- Pre-migration / very-early fallback to the legacy inline config. - adDB = adDB or (S.db and S.db.auraDesigner) - if adDB and (not adDB._specScopedV1 or not adDB._specScopedV2) then - MigrateToSpecScoped(adDB) - end - MigrateInstancesLazy(adDB) - MigrateBorderKeysLazy(adDB) - MigratePrioritiesLazy(adDB) - -- MUST match the render-path list in Factory.lua exactly. If the editor resolves an adDB - -- the render has not touched yet (a preset, or an auto-layout overlay not currently shown) - -- and skips a migration, the editor shows UN-migrated values -- and anything saved from that - -- state gets migrated a second time when the render finally runs. - MigrateAbsoluteLevelsLazy(adDB) - MigrateAbsoluteLevelsV2Lazy(adDB) - MigrateDefaultRefreshLazy(adDB) - return adDB -end -P.GetAuraDesignerDB = GetAuraDesignerDB - -local function GetThemeColor() - return GUI.GetThemeColor() -end -P.GetThemeColor = GetThemeColor - -local function ApplyBackdrop(frame, bgColor, borderColor) - -- Build through the shared GUI backdrop once per frame, then push only - -- vertex colours below. SetBackdrop is a full rebuild, so keeping it off the - -- re-render path still matters when the AD effects list rebuilds. - if not frame.dfAD_backdropApplied then - DF.GUI:CreateElementBackdrop(frame) - frame.dfAD_backdropApplied = true - end - - if bgColor then - local r, g, b = bgColor.r, bgColor.g, bgColor.b - local a = bgColor.a or 1 - if frame.dfAD_bgR ~= r or frame.dfAD_bgG ~= g or frame.dfAD_bgB ~= b or frame.dfAD_bgA ~= a then - frame:SetBackdropColor(r, g, b, a) - frame.dfAD_bgR, frame.dfAD_bgG, frame.dfAD_bgB, frame.dfAD_bgA = r, g, b, a - end - end - - if borderColor then - local r, g, b = borderColor.r, borderColor.g, borderColor.b - local a = borderColor.a or 1 - if frame.dfAD_borderR ~= r or frame.dfAD_borderG ~= g or frame.dfAD_borderB ~= b or frame.dfAD_borderA ~= a then - frame:SetBackdropBorderColor(r, g, b, a) - frame.dfAD_borderR, frame.dfAD_borderG, frame.dfAD_borderB, frame.dfAD_borderA = r, g, b, a - end - end -end -P.ApplyBackdrop = ApplyBackdrop - --- ============================================================ --- COLLAPSIBLE CARD SHELL --- The effects list, the groups list and the debuff-category list each build the --- same thing: a card pinned to the parent's width, a 30px header button on top, --- and a chevron at its left edge that flips with the expanded state. Past that --- point the three diverge completely -- a spell icon and type badge, a group --- name and link count, a list of categories -- so this owns only the shell and --- hands back the chevron for the caller to anchor its own content to. --- --- opts = { yPos, expanded, borderColor, chevronColor (default C_TEXT_DIM) } --- Returns card, header, chevron. --- ============================================================ -local CARD_HEADER_HEIGHT = 30 -local CHEVRON_EXPANDED = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more" -local CHEVRON_COLLAPSED = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right" - -local function CreateCardShell(parent, opts) - local card = CreateFrame("Frame", nil, parent, "BackdropTemplate") - card:SetPoint("TOPLEFT", 8, opts.yPos) - card:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - - local header = CreateFrame("Button", nil, card, "BackdropTemplate") - header:SetHeight(CARD_HEADER_HEIGHT) - header:SetPoint("TOPLEFT", 0, 0) - header:SetPoint("TOPRIGHT", 0, 0) - ApplyBackdrop(header, C_ELEMENT, opts.borderColor) - - local chevron = header:CreateTexture(nil, "OVERLAY") - chevron:SetSize(12, 12) - chevron:SetPoint("LEFT", 8, 0) - chevron:SetTexture(opts.expanded and CHEVRON_EXPANDED or CHEVRON_COLLAPSED) - local cc = opts.chevronColor or C_TEXT_DIM - chevron:SetVertexColor(cc.r, cc.g, cc.b) - - return card, header, chevron -end -P.CreateCardShell = CreateCardShell - --- ============================================================ --- BUFF COEXISTENCE POPUP --- Shown once when the user enables Aura Designer, asking whether --- to keep standard buff icons or let AD fully replace them. --- ============================================================ - --- (S.buffCoexistPopup declared on the state table) - -local function ShowBuffCoexistPopup(onConfirm, onCancel) - if not S.buffCoexistPopup then - local f = CreateFrame("Frame", "DFADBuffPopup", UIParent, "BackdropTemplate") - f:SetSize(420, 130) - f:SetPoint("CENTER") - f:SetFrameStrata("FULLSCREEN_DIALOG") - f:SetFrameLevel(250) - f:EnableMouse(true) - local tc = GetThemeColor() - ApplyBackdrop(f, {r = 0.10, g = 0.10, b = 0.10, a = 0.98}, {r = tc.r, g = tc.g, b = tc.b, a = 1}) - - -- Thin accent stripe along the top - local stripe = f:CreateTexture(nil, "OVERLAY") - stripe:SetColorTexture(tc.r, tc.g, tc.b, 0.8) - stripe:SetHeight(2) - stripe:SetPoint("TOPLEFT", 1, -1) - stripe:SetPoint("TOPRIGHT", -1, -1) - f._stripe = stripe - - local title = f:CreateFontString(nil, "OVERLAY", "DFFontNormal") - title:SetPoint("TOP", 0, -12) - title:SetText(L["Aura Designer"]) - title:SetTextColor(tc.r, tc.g, tc.b) - - local desc = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - desc:SetPoint("TOP", title, "BOTTOM", 0, -6) - desc:SetWidth(390) - desc:SetText(L["Would you like to keep standard buff icons alongside\nAura Designer, or let it fully replace them?"]) - desc:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - desc:SetJustifyH("CENTER") - - local function MakeButton(parent, text, xOff) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - btn:SetPoint("BOTTOM", parent, "BOTTOM", xOff, 14) - DF.GUI:StyleButton(btn, { width = 170, height = 28, text = text }) - btn.text = btn.Text - return btn - end - - f.keepBtn = MakeButton(f, L["Keep Buffs"], -95) - f.replaceBtn = MakeButton(f, L["Replace Buffs"], 95) - - -- Close on Escape. SetPropagateKeyboardInput is protected in combat - -- for insecure code: skip it there (keys propagate anyway — ESC just - -- hides the popup). - f:SetScript("OnKeyDown", function(self, key) - if key == "ESCAPE" then - if not InCombatLockdown() then self:SetPropagateKeyboardInput(false) end - self:Hide() - if self._onCancel then self._onCancel() end - else - if not InCombatLockdown() then self:SetPropagateKeyboardInput(true) end - end - end) - - S.buffCoexistPopup = f - end - - local f = S.buffCoexistPopup - f._onCancel = onCancel - - f.keepBtn:SetScript("OnClick", function() - f:Hide() - if onConfirm then onConfirm(true) end - end) - f.replaceBtn:SetScript("OnClick", function() - f:Hide() - if onConfirm then onConfirm(false) end - end) - - f:Show() -end -P.ShowBuffCoexistPopup = ShowBuffCoexistPopup - --- Get or resolve the active spec key from settings -local function ResolveSpec() - local adDB = GetAuraDesignerDB() - if adDB.spec == "auto" then - return Adapter:GetPlayerSpec() - end - return adDB.spec -end -P.ResolveSpec = ResolveSpec - --- Track which spec aura tables have already been sanitized this session -local sanitizedSpecAuras = {} - --- Returns the spec-scoped auras sub-table, creating it if needed --- Also sanitizes corrupted entries (non-table values like stray nextIndicatorID) -local function GetSpecAuras(spec) - local adDB = GetAuraDesignerDB() - if not adDB then return {} end - if not adDB.auras then adDB.auras = {} end - spec = spec or ResolveSpec() - if not spec then return {} end - if not adDB.auras[spec] then adDB.auras[spec] = {} end - local specAuras = adDB.auras[spec] - -- One-time cleanup: remove non-table entries that ended up at the wrong level - if not sanitizedSpecAuras[specAuras] then - local toRemove - for k, v in pairs(specAuras) do - if type(v) ~= "table" then - if not toRemove then toRemove = {} end - toRemove[#toRemove + 1] = k - end - end - if toRemove then - for _, k in ipairs(toRemove) do - specAuras[k] = nil - end - DF:DebugWarn("AD", "Cleaned %d corrupted entries from spec auras table", #toRemove) - end - sanitizedSpecAuras[specAuras] = true - end - return specAuras -end -P.GetSpecAuras = GetSpecAuras - --- Returns the spec-INDEPENDENT "Other Buffs" pool (B1): a flat map auraName -> auraCfg --- with the EXACT record shape of adDB.auras[spec] entries (indicators array, frame-level --- sub-tables, sound, priority, nextIndicatorID), created + sanitized like GetSpecAuras. --- Deliberately a SIBLING key of adDB.auras (never a pseudo-spec inside it) so every --- adDB.auras consumer stays spec-scoped; a future per-spec expansion of this pool would --- land under its own sibling key, so this flat map never needs a migration. Aura names --- here must be SpellDB names (rec.n / localized) or ad-hoc "#" keys — identity --- resolves spec-independently via DF:BuildADIdentityFilters(nil, name). --- (B2 wires the Other Buffs tab/editor to this accessor; the factory reads --- adDB.otherAuras directly, mirroring how it reads adDB.auras[spec].) -local function GetOtherAuras() - local adDB = GetAuraDesignerDB() - if not adDB then return {} end - if not adDB.otherAuras then adDB.otherAuras = {} end - local otherAuras = adDB.otherAuras - -- One-time cleanup, same as GetSpecAuras (registry is keyed by table identity, so - -- profile/preset switches re-sanitize the new table naturally). - if not sanitizedSpecAuras[otherAuras] then - local toRemove - for k, v in pairs(otherAuras) do - if type(v) ~= "table" then - if not toRemove then toRemove = {} end - toRemove[#toRemove + 1] = k - end - end - if toRemove then - for _, k in ipairs(toRemove) do - otherAuras[k] = nil - end - DF:DebugWarn("AD", "Cleaned %d corrupted entries from other auras table", #toRemove) - end - sanitizedSpecAuras[otherAuras] = true - end - return otherAuras -end -P.GetOtherAuras = GetOtherAuras - --- Returns the preset's DEBUFF CATEGORY GROUPS array (C1): a flat, spec-INDEPENDENT --- array of group records (mirror of the otherAuras siblings-not-pseudo-spec rule — --- a future per-spec expansion would land under its own key, so this array never --- needs a migration). Lazily created on first WRITE access only — merely opening --- the editor must not create adDB.debuffGroups (mirror GetOtherAuras). Each record: --- { id, name, enabled, anchor, offsetX, offsetY, growDirection, iconsPerRow, --- spacing, iconSize, maxIcons, selection = { boss, role, priority, crowdControl, --- raid, dispellable, dispellableMode, hideLong, hideLongMinutes, keepImportant } }. --- The factory reads adDB.debuffGroups directly (as it reads otherAuras); the --- C2 group editor reaches it via CreateDebuffGroup / DebuffGroupsRead. -local function GetDebuffGroups() - local adDB = GetAuraDesignerDB() - if not adDB then return {} end - if not adDB.debuffGroups then adDB.debuffGroups = {} end - local groups = adDB.debuffGroups - -- One-time cleanup, same table-identity registry as GetSpecAuras/GetOtherAuras - -- (profile/preset switches swap the table and re-sanitize naturally). - if not sanitizedSpecAuras[groups] then - for i = #groups, 1, -1 do - if type(groups[i]) ~= "table" then - tremove(groups, i) - DF:DebugWarn("AD", "Removed corrupted debuff group entry at index %d", i) - end - end - sanitizedSpecAuras[groups] = true - end - return groups -end - --- Default DISPLAY NAME for a new group: one above the HIGHEST number currently in --- use for this prefix. --- --- The id counters (nextLayoutGroupID / nextOtherLayoutGroupID / nextDebuffGroupID) --- stay strictly monotonic on purpose — ids are stable references (indicator -> --- group links), so reusing one would rebind stale links to the wrong group. The --- visible LABEL has no such constraint, and deriving it from the raw id made it --- climb forever ("Group 7" on an empty designer after six create/deletes). --- --- Why highest+1 and NOT the lowest free number: the list renders in CREATION order --- (every site iterates ipairs(groups); nothing sorts them) and new groups are --- tinsert-APPENDED. Filling a gap would therefore drop a low number at the BOTTOM --- of the list — "Group 2" above "Group 1" — which reads worse than a gap. A gap is --- also honest: it says something was deleted. Because this scans the CURRENT set --- rather than a persisted counter, an emptied designer still restarts at 1. --- --- Prefixes are distinct and anchored, so "Group" won't match "Filter Group 3" (or --- vice versa); none contain Lua pattern magic characters. A user-renamed group drops --- out of the numbering pool. -local function NextGroupName(groups, prefix) - local highest = 0 - for _, g in ipairs(groups or {}) do - local n = tonumber(tostring((g and g.name) or ""):match("^" .. prefix .. " (%d+)$")) - if n and n > highest then highest = n end - end - return prefix .. " " .. (highest + 1) -end -P.NextGroupName = NextGroupName - --- Create a new debuff category group (C1 data model; C2 wires the UI). Defaults: --- Boss + Role selected (the classic "important debuffs" baseline), everything --- else off, Hide Long staged at 5 minutes with Keep Important on. Layout mirrors --- the filter-group creation defaults (compact 4x4). -local function CreateDebuffGroup(name) - local adDB = GetAuraDesignerDB() - if not adDB then return nil end - local groups = GetDebuffGroups() - if not adDB.nextDebuffGroupID then adDB.nextDebuffGroupID = 1 end - local id = adDB.nextDebuffGroupID - adDB.nextDebuffGroupID = id + 1 - local group = { - id = id, - name = name or NextGroupName(groups, "Debuff Group"), - anchor = "TOPLEFT", - offsetX = 0, - offsetY = 0, - growDirection = "RIGHT_DOWN", - iconsPerRow = 4, - spacing = 2, - iconSize = 24, - maxIcons = 4, - selection = { - boss = true, role = true, priority = false, crowdControl = false, - raid = false, dispellable = false, dispellableMode = "PLAYER", - hideLong = false, hideLongMinutes = 5, keepImportant = true, - }, - } - tinsert(groups, group) - return group -end -P.CreateDebuffGroup = CreateDebuffGroup - --- Returns the spec-scoped layout groups array, creating it if needed -local function GetSpecLayoutGroups(spec) - local adDB = GetAuraDesignerDB() - if not adDB then return {} end - if not adDB.layoutGroups then adDB.layoutGroups = {} end - spec = spec or ResolveSpec() - if not spec then return {} end - if not adDB.layoutGroups[spec] then adDB.layoutGroups[spec] = {} end - return adDB.layoutGroups[spec] -end -P.GetSpecLayoutGroups = GetSpecLayoutGroups - --- ============================================================ --- MAIN POOL TABS (B2) — My Buffs / Other Buffs --- The tab strip above the workspace decides which aura pool the ENTIRE --- editor operates on: "my" = the spec-scoped adDB.auras[spec] pool, --- "other" = the spec-INDEPENDENT adDB.otherAuras pool (B1). Every editor --- read routes through CurrentAuraPool(); spec-only surfaces (layout --- groups, migrations, the spec dropdown itself) deliberately do not. --- ============================================================ - -S.activeBuffTab = "my" -- "my" | "debuffs" | "other" - -local function IsOtherTab() - return S.activeBuffTab == "other" -end -P.IsOtherTab = IsOtherTab - --- C2: the Debuffs tab hosts debuff CATEGORY groups (spec-independent, no --- spell pool, no placed indicators). Its Effects sub-tab frosts, the spec --- dropdown greys, and CurrentAuraPool reads empty. -local function IsDebuffTab() - return S.activeBuffTab == "debuffs" -end -P.IsDebuffTab = IsDebuffTab - --- Read-only placeholder returned while the other pool doesn't exist yet. --- Merely VISITING the Other Buffs tab must not create adDB.otherAuras — --- only the first ADD does (via CurrentAuraPoolWrite). Never written. -local EMPTY_POOL = {} -P.EMPTY_POOL = EMPTY_POOL - --- READ access to the active tab's pool. Never creates adDB.otherAuras. --- `spec` is forwarded to GetSpecAuras on the My Buffs tab only. -local function CurrentAuraPool(spec) - if S.activeBuffTab == "other" then - local adDB = GetAuraDesignerDB() - if adDB and adDB.otherAuras then return GetOtherAuras() end - return EMPTY_POOL - end - -- The Debuffs tab has no aura pool: no placed indicators and no - -- frame-level effects, so every pool-routed surface (preview passes, - -- effect list) reads empty. Category groups live in adDB.debuffGroups. - if S.activeBuffTab == "debuffs" then return EMPTY_POOL end - return GetSpecAuras(spec) -end -P.CurrentAuraPool = CurrentAuraPool - --- WRITE access: creates the pool table (the other pool is born lazily on --- the first add — drag-drop, picker click, or add-by-ID). -local function CurrentAuraPoolWrite() - if S.activeBuffTab == "other" then return GetOtherAuras() end - return GetSpecAuras() -end - --- B1 key-prefix contract: wherever an editor key embeds an aura's name, --- the other-pool record embeds "other:" .. auraName in the name segment --- (expandedCards "placed:other:#" / "frame::other:", --- preview slot keys). The auraName itself never carries the prefix. -local function PoolKeyPrefix() - return (S.activeBuffTab == "other") and "other:" or "" -end -P.PoolKeyPrefix = PoolKeyPrefix - --- READ access to the debuff category groups array (C2). Never creates --- adDB.debuffGroups — merely visiting the Debuffs tab must not write --- (the EMPTY_POOL rule above); the first "+ Debuff Group" add creates it --- via CreateDebuffGroup → GetDebuffGroups. -local function DebuffGroupsRead() - local adDB = GetAuraDesignerDB() - if adDB and adDB.debuffGroups then return GetDebuffGroups() end - return EMPTY_POOL -end -P.DebuffGroupsRead = DebuffGroupsRead - --- Other Buffs LAYOUT GROUPS: a flat, spec-INDEPENDENT array of group records --- over the other pool (sibling of otherAuras/debuffGroups — never a pseudo-spec --- inside adDB.layoutGroups, so no migration can ever touch it). Records are --- shape-identical to the spec-keyed layoutGroups entries (member kind with --- {auraName, indicatorID} members referencing OTHER-pool placed indicators, --- and filter kind with filterSelection). Own id counter --- (adDB.nextOtherLayoutGroupID — ids can collide with the spec counter's, so --- every derived key carries a pool marker). One accessor for both access --- modes (the file-scope 200-locals ceiling forbids the two-function --- GetDebuffGroups/DebuffGroupsRead split): `create` follows the lazy-write --- rule — read access returns EMPTY_POOL while the store doesn't exist --- (visiting the tab must never create it; the first "+ Create/Filter Group" --- add passes create=true). -local function GetOtherLayoutGroups(create) - local adDB = GetAuraDesignerDB() - if not adDB then return EMPTY_POOL end - if not adDB.otherLayoutGroups then - if not create then return EMPTY_POOL end - adDB.otherLayoutGroups = {} - end - local groups = adDB.otherLayoutGroups - -- One-time cleanup, same table-identity registry as GetDebuffGroups. - if not sanitizedSpecAuras[groups] then - for i = #groups, 1, -1 do - if type(groups[i]) ~= "table" then - tremove(groups, i) - DF:DebugWarn("AD", "Removed corrupted other layout group entry at index %d", i) - end - end - sanitizedSpecAuras[groups] = true - end - return groups -end -P.GetOtherLayoutGroups = GetOtherLayoutGroups - --- Active tab's layout groups (READ — never creates the other store): the --- flat other store on Other Buffs, the spec-keyed array on My Buffs. --- (Debuffs never reaches these — its Layout Groups tab builds debuff --- category groups instead.) -local function CurrentLayoutGroups() - if S.activeBuffTab == "other" then return GetOtherLayoutGroups(false) end - return GetSpecLayoutGroups() -end -P.CurrentLayoutGroups = CurrentLayoutGroups - --- Display name for an OTHER-pool aura key: ad-hoc "#" resolves live, --- SpellDB names resolve through GetSpellDisplay (localized), else the raw key. -local function OtherPoolDisplayName(auraName) - local id = type(auraName) == "string" and auraName:match("^#(%d+)$") - if id then - local live = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(tonumber(id)) - return live or auraName - end - local R = DF.FilterRegistry - local rec = R and R.GetSpellByName and R:GetSpellByName(auraName) - if rec then - local display = R:GetSpellDisplay(rec) - if display then return display end - end - return auraName -end -P.OtherPoolDisplayName = OtherPoolDisplayName - --- Union of every spell ID tracked by one pool of an adDB (cross-tab --- used-affordance). which = "my" walks ALL spec pools (the preset object is --- one: a spell tracked for ANY spec would double-render for that spec when --- also in the shared other pool); "other" walks the flat other pool with --- nil-spec identity. Pure (no editor state) — exposed for the SDD harness. -local function PoolTrackedIDs(adDB, which) - local out = {} - if type(adDB) ~= "table" then return out end - local function addIDs(spec, auraName) - local f = DF:BuildADIdentityFilters(spec, auraName) - local map = f and f.includeSpellIDs - if map then - for id in pairs(map) do out[id] = true end - end - end - if which == "my" then - if type(adDB.auras) == "table" then - for specKey, specAuras in pairs(adDB.auras) do - if type(specAuras) == "table" then - for auraName, cfg in pairs(specAuras) do - if type(cfg) == "table" then addIDs(specKey, auraName) end - end - end - end - end - else -- "other" - if type(adDB.otherAuras) == "table" then - for auraName, cfg in pairs(adDB.otherAuras) do - if type(cfg) == "table" then addIDs(nil, auraName) end - end - end - end - return out -end -DF.ADEditor_PoolTrackedIDs = PoolTrackedIDs - --- IDs tracked by the OPPOSITE pool of the active tab (drives the picker's --- cross-tab blocked rows and the add-by-ID gate). -local function CrossPoolTrackedIDs() - return PoolTrackedIDs(GetAuraDesignerDB(), IsOtherTab() and "my" or "other") -end -P.CrossPoolTrackedIDs = CrossPoolTrackedIDs - --- Ensure an aura config table exists, creating it with defaults if needed. --- `pool` (optional) pins the target pool — proxies capture their pool at --- creation so a stale color-picker callback can't write across tabs. -local function EnsureAuraConfig(auraName, pool) - local auras = pool or CurrentAuraPoolWrite() - if not auras[auraName] then - auras[auraName] = { - priority = 5, - } - end - return auras[auraName] -end -P.EnsureAuraConfig = EnsureAuraConfig - --- Ensure a type sub-table exists within an aura config -local function EnsureTypeConfig(auraName, typeKey, pool) - local auraCfg = EnsureAuraConfig(auraName, pool) - if not auraCfg[typeKey] then - -- Read global defaults so new configs inherit user-configured values - local adDB = GetAuraDesignerDB() - local gd = adDB and adDB.defaults or {} - - -- Create default config for each type - if typeKey == "icon" then - auraCfg[typeKey] = { - -- Placement - anchor = "TOPLEFT", offsetX = 0, offsetY = 0, - -- Size & appearance (from global defaults) - size = gd.iconSize or 24, scale = gd.iconScale or 1.0, alpha = 1.0, - -- Border - -- Canonical border keys (Stage 5.1b/c). Legacy names were - -- borderEnabled / borderThickness / borderInset; migrated - -- via DF:MigrateAuraDesignerIconBorderKeys on ADDON_LOADED. - -- ShowBorder/BorderSize/BorderInset are stored on the - -- aura's icon sub-config; everything else (style, colour, - -- gradient, shadow, offset, blend) reads from TYPE_DEFAULTS - -- via proxy fall-through until the user overrides it. - -- Seed Show/Size from the global icon-border defaults so the - -- "Import Buffs Tab Defaults" border toggle actually carries over. - ShowBorder = (gd.iconBorderEnabled ~= false), BorderSize = gd.iconBorderThickness or 1, BorderInset = 0, - hideSwipe = false, - -- Duration text - showDuration = gd.showDuration ~= false, - durationFont = gd.durationFont or "DF Roboto SemiBold", - durationScale = gd.durationScale or 1.0, - durationOutline = gd.durationOutline or "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = true, -- DELIBERATE hardcode (v4 parity): new placements always start coloured, even in profiles whose global default is OFF — Krathe's call. Already-placed indicators are never touched. - -- Stack count - showStacks = gd.showStacks ~= false, - stackFont = gd.stackFont or "DF Roboto SemiBold", - stackScale = gd.stackScale or 1.0, - stackOutline = gd.stackOutline or "SHADOW;OUTLINE", - stackAnchor = "BOTTOMRIGHT", - stackX = 2, stackY = -2, - } - elseif typeKey == "square" then - auraCfg[typeKey] = { - -- Placement - anchor = "TOPLEFT", offsetX = 0, offsetY = 0, - -- Appearance (from global defaults) - size = gd.iconSize or 24, scale = gd.iconScale or 1.0, alpha = 1.0, - color = {r = 1, g = 1, b = 1, a = 1}, - -- Border (canonical keys, Stage 5.2; legacy migrated on load) - ShowBorder = true, BorderSize = 1, BorderInset = 0, - hideSwipe = false, - -- Duration text - showDuration = gd.showDuration ~= false, - durationFont = gd.durationFont or "DF Roboto SemiBold", - durationScale = gd.durationScale or 1.0, - durationOutline = gd.durationOutline or "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = true, -- DELIBERATE hardcode (v4 parity): new placements always start coloured, even in profiles whose global default is OFF — Krathe's call. Already-placed indicators are never touched. - -- Stack count - showStacks = gd.showStacks ~= false, - stackFont = gd.stackFont or "DF Roboto SemiBold", - stackScale = gd.stackScale or 1.0, - stackOutline = gd.stackOutline or "SHADOW;OUTLINE", - stackAnchor = "BOTTOMRIGHT", - stackX = 2, stackY = -2, - } - elseif typeKey == "bar" then - auraCfg[typeKey] = { - -- Placement - anchor = "BOTTOM", offsetX = 0, offsetY = 0, - -- Size & orientation - orientation = "HORIZONTAL", width = 60, height = 6, - matchFrameWidth = true, matchFrameHeight = false, - -- Texture & colors - texture = "Interface\\TargetingFrame\\UI-StatusBar", - fillColor = {r = 1, g = 1, b = 1, a = 1}, - bgColor = {r = 0, g = 0, b = 0, a = 0.5}, - -- Border (canonical keys, Stage 5.3; legacy migrated on load) - ShowBorder = true, BorderSize = 1, BorderInset = 0, - BorderColor = {r = 0, g = 0, b = 0, a = 1}, - -- Alpha - alpha = 1.0, - -- Bar color by time - barColorByTime = false, - -- Duration text - showDuration = true, - durationFont = gd.durationFont or "DF Roboto SemiBold", - durationScale = gd.durationScale or 1.0, - durationOutline = gd.durationOutline or "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = true, -- DELIBERATE hardcode (v4 parity): new placements always start coloured, even in profiles whose global default is OFF — Krathe's call. Already-placed indicators are never touched. - } - elseif typeKey == "border" then - auraCfg[typeKey] = { - -- Border (canonical keys, Stage 5.4; legacy style/thickness/ - -- inset/color migrated on load) - ShowBorder = true, BorderStyle = "SOLID", BorderSize = 2, BorderInset = 0, - BorderColor = {r = 1, g = 1, b = 1, a = 1}, - drawAboveFrameBorder = true, - showWhenMissing = false, - } - elseif typeKey == "healthbar" then - auraCfg[typeKey] = { - mode = "Replace", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, - tintWholeBar = false, - showWhenMissing = false, - } - elseif typeKey == "background" then - auraCfg[typeKey] = { - mode = "Tint", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, - showWhenMissing = false, - } - elseif typeKey == "nametext" then - auraCfg[typeKey] = { - color = {r = 1, g = 1, b = 1, a = 1}, - showWhenMissing = false, - } - elseif typeKey == "healthtext" then - auraCfg[typeKey] = { - color = {r = 1, g = 1, b = 1, a = 1}, - showWhenMissing = false, - } - elseif typeKey == "sound" then - auraCfg[typeKey] = { - enabled = false, - soundFile = nil, - soundLSMKey = nil, - volume = 0.8, - -- Per-event native sounds (12.1 AddAuraSound triggers). The flat sound - -- above is the APPLIED sound (Added trigger). dropped = Removed (buff - -- fell off), stackGained = ApplicationsIncreased (stack gained). There is - -- no stacks-lost (no ApplicationsDecreased trigger). Distinct from the - -- blocked Missing/Expire alerts, which need sealed presence/remaining-time. - appliedEnabled = true, - dropped = { enabled = false, soundLSMKey = nil, soundFile = nil }, - stackGained = { enabled = false, soundLSMKey = nil, soundFile = nil }, - } - end - end - return auraCfg[typeKey] -end -P.EnsureTypeConfig = EnsureTypeConfig - --- Default values per type key, used as fallback when a saved config is missing new keys -local TYPE_DEFAULTS = { - icon = { - anchor = "TOPLEFT", offsetX = 0, offsetY = 0, - size = 24, scale = 1.0, alpha = 1.0, - -- Canonical border keys (Stage 5.1b/c). Legacy borderEnabled / - -- borderThickness / borderInset migrated on ADDON_LOADED via - -- DF:MigrateAuraDesignerIconBorderKeys. BorderColor defaults to - -- the pre-migration hardcoded translucent black so existing users - -- see no visual change. Style / Gradient* / Shadow* defaults seed - -- CreateBorderControls' dropdowns and pickers so they read sensible - -- values on first open. - ShowBorder = true, BorderSize = 1, BorderInset = 0, - BorderColor = {r = 0, g = 0, b = 0, a = 0.8}, - BorderStyle = "SOLID", - BorderBlendMode = "BLEND", - BorderOffsetX = 0, - BorderOffsetY = 0, - BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, - BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, - BorderGradientDirection = "HORIZONTAL", - BorderShadowEnabled = false, - BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, - BorderShadowSize = 1, - BorderShadowOffsetX = 1, - BorderShadowOffsetY = -1, - -- Animation defaults match Frame Border's Stage 3 defaults so the - -- behaviour of "pick PULSATE" reads the same across the addon. - -- BorderAnimationType = "NONE" means no continuous animation; the - -- spec.animation block is omitted by BuildSpec so Apply doesn't - -- start anything. Picking a non-NONE type surfaces the relevant - -- tunables (helper handles hide/show per effect). - BorderAnimationType = "NONE", - BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, - -- 1 Hz default ≈ 1-second cycle, matching the legacy AD Pulsate - -- Border pulse rate. Frame Border / Defensive Icon use 0.25 which - -- reads as a slow gentle pulse at full-frame scale; at icon scale - -- (24px) the same rate looks like a static dim border because the - -- transitions are too gradual to perceive. - BorderAnimationFrequency = 1, - BorderAnimationParticles = 8, - BorderAnimationLength = 8, - BorderAnimationThickness = 3, - BorderAnimationScale = 1, - BorderAnimationInset = 0, - BorderAnimationOffsetX = 0, - BorderAnimationOffsetY = 0, - BorderAnimationMask = false, - BorderAnimationSidesAxis = "HORIZONTAL", - BorderAnimationCornerLength = 10, - hideSwipe = false, hideIcon = false, - showDuration = true, durationFormat = "NUMBER", durationFont = "DF Roboto SemiBold", - durationScale = 1.2, durationOutline = "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = true, - durationColor = {r = 1, g = 1, b = 1, a = 1}, - durationHideAboveEnabled = false, durationHideAboveThreshold = 10, - durationHideOnPermanent = true, -- Wave 4: no timer text on permanent auras - expiryAlertEnabled = false, expiryAlertMode = "BORDER", expiryAlertThreshold = 5, expiryAlertThresholdPercent = 30, expiryAlertThresholdUnit = "SECONDS", - expiryAlertText = "", expiryAlertGlyph = "WARNING", - expiryAlertAnchor = "TOP", expiryAlertOffsetX = 0, expiryAlertOffsetY = 0, - expiryAlertSize = 14, - -- Expiry Alert BORDER mode (secret-safe expiring frame): colour + auto-match + inset. - expiryAlertBorderMatchIcon = true, expiryAlertBorderInset = 0, - expiryAlertBorderColorMode = "STATIC", expiryAlertBorderThickness = "MEDIUM", - expiryAlertBorderAlpha = 1, - expiryAlertBorderColor = {r = 1, g = 0.2, b = 0.2, a = 1}, - showStacks = true, - stackFont = "DF Roboto SemiBold", stackScale = 1.0, - stackOutline = "SHADOW;OUTLINE", stackAnchor = "BOTTOMRIGHT", - stackX = 2, stackY = -2, - stackColor = {r = 1, g = 1, b = 1, a = 1}, - -- Expiring Tint overlay (secret-safe). Default OFF, red — also feeds the - -- colour picker's Default button via the proxy's __dfDefaults = TYPE_DEFAULTS. - -- #FF3333 @ 50% (matches expiring border red) - -- legacy; migrated to ExpiringAnimationType - -- Master enable for the whole Expiring feature. Default true so - -- existing configs are unaffected; turning it OFF disables every - -- expiring override (colour / thickness / alpha / animation / pulse / - -- bounce) regardless of their individual settings, and hides the rest - -- of the Expiring panel. - -- Stage 5.1d.2 + parity: full Border Animation effect set as the value - -- the expiring callback swaps into spec.animation when remaining < - -- threshold. NONE = no animation override. The expiring animation - -- carries its OWN complete tunable set (colour, particles, thickness, - -- offset, …) independent of the base Border Animation — mirrors the - -- base defaults so the two panels read identically. - -- Stage 5.1d.3: per-state thickness + alpha overrides. Default to - -- 1 / 1 — same thickness as the base (1) and slightly more opaque - -- than the base alpha (0.8), so out of the box a user enabling - -- Expiring Color Override sees the border tick to fully opaque red - -- below threshold (subtle "more solid" feel). Move the sliders - -- higher / lower for stronger emphasis. Only take effect when the - -- expiring ticker is running (i.e. user has at least one expiring - -- feature on — colour override, animation, alpha pulse, or bounce). - -- Duration bar strip (mirrors the buff/debuff rows + filter-group cards): - -- a native SetDurationBar-driven strip under/over the icon. OFF by default; - -- render fallbacks live in DF:BuildDurationBarSpec — these seed the editor. - durationBarEnabled = false, durationBarPosition = "BOTTOM", - durationBarHeight = 4, durationBarGap = 1, durationBarColorMode = "STATIC", - durationBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", - durationBarColor = {r = 0.2, g = 0.9, b = 0.3, a = 1}, - durationBarBGColor = {r = 0, g = 0, b = 0, a = 0.8}, - durationBarReverseFill = false, - frameLevel = 40, frameStrata = "INHERIT", - showWhenMissing = false, missingDesaturate = false, - }, - square = { - anchor = "TOPLEFT", offsetX = 0, offsetY = 0, - size = 24, scale = 1.0, alpha = 1.0, - color = {r = 1, g = 1, b = 1, a = 1}, - -- Canonical border keys (Stage 5.2). Legacy showBorder / - -- borderThickness / borderInset migrated on ADDON_LOADED. BorderColor - -- defaults to opaque black, matching the square's pre-migration - -- hardcoded border so existing users see no change. The rest seed - -- CreateBorderControls' dropdowns / pickers on first open. - ShowBorder = true, BorderSize = 1, BorderInset = 0, - BorderColor = {r = 0, g = 0, b = 0, a = 1}, - BorderStyle = "SOLID", - BorderBlendMode = "BLEND", - BorderOffsetX = 0, - BorderOffsetY = 0, - BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, - BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, - BorderGradientDirection = "HORIZONTAL", - BorderShadowEnabled = false, - BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, - BorderShadowSize = 1, - BorderShadowOffsetX = 1, - BorderShadowOffsetY = -1, - BorderAnimationType = "NONE", - BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, - BorderAnimationFrequency = 1, - BorderAnimationParticles = 8, - BorderAnimationLength = 8, - BorderAnimationThickness = 3, - BorderAnimationScale = 1, - BorderAnimationInset = 0, - BorderAnimationOffsetX = 0, - BorderAnimationOffsetY = 0, - BorderAnimationMask = false, - BorderAnimationSidesAxis = "HORIZONTAL", - BorderAnimationCornerLength = 10, - hideSwipe = false, hideIcon = false, - showDuration = true, durationFormat = "NUMBER", durationFont = "DF Roboto SemiBold", - durationScale = 1.2, durationOutline = "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = true, - durationColor = {r = 1, g = 1, b = 1, a = 1}, - durationHideAboveEnabled = false, durationHideAboveThreshold = 10, - durationHideOnPermanent = true, -- Wave 4: no timer text on permanent auras - expiryAlertEnabled = false, expiryAlertMode = "BORDER", expiryAlertThreshold = 5, expiryAlertThresholdPercent = 30, expiryAlertThresholdUnit = "SECONDS", - expiryAlertText = "", expiryAlertGlyph = "WARNING", - expiryAlertAnchor = "TOP", expiryAlertOffsetX = 0, expiryAlertOffsetY = 0, - expiryAlertSize = 14, - -- Expiry Alert BORDER mode (secret-safe expiring frame): colour + auto-match + inset. - expiryAlertBorderMatchIcon = true, expiryAlertBorderInset = 0, - expiryAlertBorderColorMode = "STATIC", expiryAlertBorderThickness = "MEDIUM", - expiryAlertBorderAlpha = 1, - expiryAlertBorderColor = {r = 1, g = 0.2, b = 0.2, a = 1}, - showStacks = true, - stackFont = "DF Roboto SemiBold", stackScale = 1.0, - stackOutline = "SHADOW;OUTLINE", stackAnchor = "BOTTOMRIGHT", - stackX = 2, stackY = -2, - stackColor = {r = 1, g = 1, b = 1, a = 1}, - -- Master enable for the whole Expiring feature (Stage 5.2 — mirrors - -- the icon). Default true so existing configs are unaffected. - -- #FF3333 @ 50% (matches expiring border red) - -- Stage 5.2 expiring-border overrides (shared backend with the icon). - -- ExpiringBorderColor is SEPARATE from the fill's expiringColor — the - -- fill and border each get their own expiring tint. Defaults to the - -- same red so out of the box both "turn red", but they're independent. - -- Duration bar strip (mirrors the icon card): a native SetDurationBar-driven - -- strip under/over the square. OFF by default; render fallbacks live in - -- DF:BuildDurationBarSpec — these seed the editor. - durationBarEnabled = false, durationBarPosition = "BOTTOM", - durationBarHeight = 4, durationBarGap = 1, durationBarColorMode = "STATIC", - durationBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", - durationBarColor = {r = 0.2, g = 0.9, b = 0.3, a = 1}, - durationBarBGColor = {r = 0, g = 0, b = 0, a = 0.8}, - durationBarReverseFill = false, - frameLevel = 40, frameStrata = "INHERIT", - showWhenMissing = false, - }, - bar = { - anchor = "BOTTOM", offsetX = 0, offsetY = 0, - orientation = "HORIZONTAL", width = 60, height = 6, - matchFrameWidth = true, matchFrameHeight = false, - barColorMode = "STATIC", -- STATIC / DF / DFSTOPS / CLASSIC (curve = green->red ramp as it drains) - texture = "Interface\\TargetingFrame\\UI-StatusBar", - fillColor = {r = 1, g = 1, b = 1, a = 1}, - bgColor = {r = 0, g = 0, b = 0, a = 0.5}, - -- Canonical border keys (Stage 5.3). Legacy showBorder / - -- borderThickness / borderColor migrated on ADDON_LOADED. BorderInset - -- defaults to 0 so the ring sits FLUSH outside the bar as before. - -- BorderColor defaults to opaque black (the bar's pre-migration look). - ShowBorder = true, BorderSize = 1, BorderInset = 0, - BorderColor = {r = 0, g = 0, b = 0, a = 1}, - BorderStyle = "SOLID", - BorderBlendMode = "BLEND", - BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, - BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, - BorderGradientDirection = "HORIZONTAL", - BorderShadowEnabled = false, - BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, - BorderShadowSize = 1, - BorderShadowOffsetX = 1, - BorderShadowOffsetY = -1, - BorderAnimationType = "NONE", - BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, - BorderAnimationFrequency = 1, - BorderAnimationParticles = 8, - BorderAnimationLength = 8, - BorderAnimationThickness = 3, - BorderAnimationScale = 1, - BorderAnimationInset = 0, - BorderAnimationOffsetX = 0, - BorderAnimationOffsetY = 0, - BorderAnimationMask = false, - BorderAnimationSidesAxis = "HORIZONTAL", - BorderAnimationCornerLength = 10, - alpha = 1.0, - barColorByTime = false, - -- #FF3333 @ 50% (matches expiring border red) - -- FALSE to match the factory's bar render default (buildDurationTextSpec is - -- called with defaultShow = false for bars — no text out of the box). It was - -- `true` here, so a never-toggled bar showed a TICKED checkbox while rendering - -- no text — and durationFmtKey's early-return ("") meant Duration Format - -- changes never moved the struct sig, reading as "stale until I toggle - -- Show Duration" (Krathe, 2026-07-24). Instances are created SPARSE - -- (CreateIndicatorInstance), so this fallback IS the checkbox for new bars. - showDuration = false, durationFormat = "NUMBER", durationFont = "DF Roboto SemiBold", - durationScale = 1.2, durationOutline = "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = true, - durationHideAboveEnabled = false, durationHideAboveThreshold = 10, - durationHideOnPermanent = true, -- Wave 4: no timer text on permanent auras - -- A bar's expiry COLOUR is its own fill (the Duration Bar Color Mode reddens as it - -- drains); the |T reveal only offers Text / Glyph here, so default to a warning Glyph. - expiryAlertEnabled = false, expiryAlertMode = "GLYPH", expiryAlertThreshold = 5, expiryAlertThresholdPercent = 30, expiryAlertThresholdUnit = "SECONDS", - expiryAlertText = "", expiryAlertGlyph = "WARNING", - expiryAlertAnchor = "TOP", expiryAlertOffsetX = 0, expiryAlertOffsetY = 0, - expiryAlertSize = 14, - frameLevel = 40, frameStrata = "INHERIT", - }, - -- Frame-level types: mirror the inline literals in EnsureTypeConfig so the - -- colour-picker Default button (and any other consumer of __dfDefaults) can - -- resolve a default value for keys like "color" and "expiringColor". - -- Border-type (Stage 5.4): full canonical DF.Border defaults so - -- CreateBorderControls' dropdowns / pickers read sensible values. The - -- legacy style/thickness/inset/color are migrated on load. - border = { - ShowBorder = true, BorderSize = 2, BorderInset = 0, - BorderColor = {r = 1, g = 1, b = 1, a = 1}, - BorderStyle = "SOLID", - BorderBlendMode = "BLEND", - BorderOffsetX = 0, - BorderOffsetY = 0, - BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, - BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, - BorderGradientDirection = "HORIZONTAL", - BorderShadowEnabled = false, - BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, - BorderShadowSize = 1, - BorderShadowOffsetX = 1, - BorderShadowOffsetY = -1, - BorderAnimationType = "NONE", - BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, - BorderAnimationFrequency = 1, - BorderAnimationParticles = 8, - BorderAnimationLength = 8, - BorderAnimationThickness = 3, - BorderAnimationScale = 1, - BorderAnimationInset = 0, - BorderAnimationOffsetX = 0, - BorderAnimationOffsetY = 0, - BorderAnimationMask = false, - BorderAnimationSidesAxis = "HORIZONTAL", - BorderAnimationCornerLength = 10, - -- Draw above the frame's class border (parent+10) / aggro (parent+9). - drawAboveFrameBorder = true, - -- Expiring-border overrides (Stage 5.4 parity with icon/square). - showWhenMissing = false, - }, - healthbar = { - mode = "Replace", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, - tintWholeBar = false, - showWhenMissing = false, - }, - background = { - mode = "Tint", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, - showWhenMissing = false, - }, - nametext = { - color = {r = 1, g = 1, b = 1, a = 1}, - showWhenMissing = false, - }, - healthtext = { - color = {r = 1, g = 1, b = 1, a = 1}, - showWhenMissing = false, - }, -} -P.TYPE_DEFAULTS = TYPE_DEFAULTS - --- ============================================================ --- INSTANCE-BASED INDICATOR HELPERS --- Placed indicators (icon/square/bar) are stored as instances --- in auraCfg.indicators[] with stable IDs. --- ============================================================ - --- Create a new indicator instance for an aura, returns the instance table -local function CreateIndicatorInstance(auraName, typeKey) - local auraCfg = EnsureAuraConfig(auraName) - if not auraCfg.indicators then - auraCfg.indicators = {} - end - if not auraCfg.nextIndicatorID then - auraCfg.nextIndicatorID = 1 - end - - -- Only store id, type, and anchor — all other settings fall through - -- to global defaults then TYPE_DEFAULTS via CreateInstanceProxy - local defaults = TYPE_DEFAULTS[typeKey] - - -- Create minimal instance: just id + type + anchor placement - local instance = { - anchor = defaults and defaults.anchor or "TOPLEFT", - offsetX = 0, - offsetY = 0, - } - - instance.id = auraCfg.nextIndicatorID - instance.type = typeKey - auraCfg.nextIndicatorID = auraCfg.nextIndicatorID + 1 - - tinsert(auraCfg.indicators, instance) - return instance -end -P.CreateIndicatorInstance = CreateIndicatorInstance - --- Find an indicator instance by its stable ID. `pool` (optional) pins the --- pool; defaults to the active tab's pool. -local function GetIndicatorByID(auraName, indicatorID, pool) - local auraCfg = (pool or CurrentAuraPool())[auraName] - if not auraCfg or not auraCfg.indicators then return nil end - for _, inst in ipairs(auraCfg.indicators) do - if inst.id == indicatorID then - return inst - end - end - return nil -end - --- Remove an indicator instance by its stable ID --- (S.CleanupAdHocAura declared on the state table) -local function RemoveIndicatorInstance(auraName, indicatorID) - local auraCfg = CurrentAuraPool()[auraName] - if not auraCfg or not auraCfg.indicators then return end - for i, inst in ipairs(auraCfg.indicators) do - if inst.id == indicatorID then - table.remove(auraCfg.indicators, i) - if S.CleanupAdHocAura then S.CleanupAdHocAura(auraName) end - return - end - end -end -P.RemoveIndicatorInstance = RemoveIndicatorInstance - --- (ChangeInstanceType removed — uncalled since the tile-strip type switcher --- left the v4 redesign; reclaimed for the 200-locals ceiling.) - --- Keys to skip when copying appearance between indicators (identity + placement + --- the eye toggle's hidden state — copying appearance must not hide the destination) -local COPY_SKIP_KEYS = { id = true, type = true, anchor = true, offsetX = true, offsetY = true, enabled = true } - --- Deep-copy a value (handles nested tables like color = {r,g,b,a}) -local function DeepCopyValue(val) - if type(val) == "table" then - local copy = {} - for k, v in pairs(val) do - copy[k] = DeepCopyValue(v) - end - return copy - end - return val -end - --- Copy appearance settings from one placed indicator to another of the same type. --- Copies all keys except identity (id, type) and placement (anchor, offsetX, offsetY). --- Keys present on source are deep-copied; keys absent on source are removed from --- destination so they fall through to defaults via the proxy chain. -local function CopyIndicatorAppearance(srcAuraName, srcIndicatorID, dstAuraName, dstIndicatorID) - local src = GetIndicatorByID(srcAuraName, srcIndicatorID) - local dst = GetIndicatorByID(dstAuraName, dstIndicatorID) - if not src or not dst then return end - if src.type ~= dst.type then return end - - -- Collect all non-skip keys from both source and destination - local allKeys = {} - for k in pairs(src) do - if not COPY_SKIP_KEYS[k] then allKeys[k] = true end - end - for k in pairs(dst) do - if not COPY_SKIP_KEYS[k] then allKeys[k] = true end - end - - -- Sync: copy from src, clear from dst what src doesn't have - for k in pairs(allKeys) do - if src[k] ~= nil then - dst[k] = DeepCopyValue(src[k]) - else - dst[k] = nil - end - end -end -P.CopyIndicatorAppearance = CopyIndicatorAppearance - --- Forward declaration: lightweight preview refresh (defined after RefreshPreviewEffects) --- Called from proxy __newindex so every setting change updates the preview in real-time --- (S.RefreshPreviewLightweight declared on the state table) - --- Throttled live-frame refresh: re-syncs the factory containers on all --- visible AD-enabled frames. Debounced so rapid slider drags only trigger one refresh. -S.pendingLiveRefresh = false -local function RefreshLiveFramesThrottled() - if S.pendingLiveRefresh then return end - S.pendingLiveRefresh = true - C_Timer.After(0.1, function() - S.pendingLiveRefresh = false - local engine = DF.AuraDesigner and DF.AuraDesigner.Engine - if engine and engine.ForceRefreshAllFrames then - engine:ForceRefreshAllFrames() - end - end) -end -P.RefreshLiveFramesThrottled = RefreshLiveFramesThrottled - --- Global-default key mapping: which global default keys apply to placed types -local GLOBAL_DEFAULT_MAP = { - icon = { - size = "iconSize", scale = "iconScale", showDuration = "showDuration", showStacks = "showStacks", - durationFont = "durationFont", durationScale = "durationScale", durationOutline = "durationOutline", - durationAnchor = "durationAnchor", durationX = "durationX", durationY = "durationY", - durationColorByTime = "durationColorByTime", durationColor = "durationColor", - stackFont = "stackFont", stackScale = "stackScale", stackOutline = "stackOutline", - stackAnchor = "stackAnchor", stackX = "stackX", stackY = "stackY", - stackColor = "stackColor", - hideSwipe = "hideSwipe", hideIcon = "hideIcon", - frameLevel = "indicatorFrameLevel", frameStrata = "indicatorFrameStrata", - }, - square = { - size = "iconSize", scale = "iconScale", showDuration = "showDuration", showStacks = "showStacks", - durationFont = "durationFont", durationScale = "durationScale", durationOutline = "durationOutline", - durationAnchor = "durationAnchor", durationX = "durationX", durationY = "durationY", - durationColorByTime = "durationColorByTime", durationColor = "durationColor", - stackFont = "stackFont", stackScale = "stackScale", stackOutline = "stackOutline", - stackAnchor = "stackAnchor", stackX = "stackX", stackY = "stackY", - stackColor = "stackColor", - hideSwipe = "hideSwipe", hideIcon = "hideIcon", - frameLevel = "indicatorFrameLevel", frameStrata = "indicatorFrameStrata", - }, - bar = { - durationFont = "durationFont", durationScale = "durationScale", durationOutline = "durationOutline", - durationAnchor = "durationAnchor", durationX = "durationX", durationY = "durationY", - durationColorByTime = "durationColorByTime", - frameLevel = "indicatorFrameLevel", frameStrata = "indicatorFrameStrata", - }, -} - --- "Expiration" section for the placed icon/square cards: the per-indicator EXPIRY ALERT --- ELEMENT (text / glyph / border / tint shown only below the threshold, natively driven on an --- invisible COMPANION SLOT over the indicator — see Factory.lua's EXPIRY ALERT COMPANION SLOT --- section). Built by the shared GUI:CreateExpirationControls helper (engine-driven via --- DF.Expiration) so the frame-level indicators can reuse the exact same panel. Controls that --- don't apply to the current mode HIDE (rows collapse + the card reflows); ones that apply but --- are inactive GREY. No animation control: a button-child region can't be animated while auras --- are secret (PTR-5), and out-of-combat-only animation is worthless for an expiry warning. --- `include` (optional) selects which reveal types + controls apply to this indicator's shape: --- square indicators (icon/square) pass nil (Border + Tint + Match); a rectangular one (bar) --- passes { border = false, tint = false, match = false } — Border distorts off-square, and the --- |T tint is font-coupled so it collapses on a thin bar; a bar's expiry colour is its own fill --- (Duration Bar Color Mode), leaving Text/Glyph as the bar's alert types. -local function AddExpiryAlertControls(g, parent, proxy, include) - GUI:CreateExpirationControls(g, proxy, { - parent = parent, - anchorOptions = OPTS.ANCHOR_OPTIONS, - include = include, - -- Sub-table colour / alpha writes skip the proxy __newindex, so drive the refresh by - -- hand (S.RefreshPreviewLightweight is assigned by editor open; mirrors the card's RPL). - fullUpdate = function() - if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end - RefreshLiveFramesThrottled() - end, - -- A mode change collapses the now-irrelevant rows: LayoutChildren re-evaluates hideOn, - -- RefreshChildStates re-applies the grey, and dfAD_ReflowWidgets slides the sibling - -- groups (Duration Text / Stack Count) up or down to track the new height. - refreshStates = function() - g:LayoutChildren() - g:RefreshChildStates() - if parent.dfAD_ReflowWidgets then parent.dfAD_ReflowWidgets() end - end, - }) - g:RefreshChildStates() -- initial grey (the initial hide rides AddGroup's LayoutChildren) -end -P.AddExpiryAlertControls = AddExpiryAlertControls - --- Colours-S.page cross-link placed under an AD "Color by Time Remaining" TEXT control, matching --- the aura pages' duration link (jump + whole-section flash). The duration text's By-Time colour --- draws from the shared Colours-S.page breakpoints, so the link points there. Fixed-layout note, so --- size it to the group's inner width up front (the group advances Y by the height we pass). --- Built once in GUI:CreateColorsPageLink. NOT for the bar FILL colour (fixed ramp, immutable). -local function AddDurationColorsLink(g, parent) - local innerW = math.max(40, (g:GetWidth() or 260) - 2 * (g.padding or 10)) - local note = GUI:CreateColorsPageLink(parent, innerW) - g:AddWidget(note, (note.layoutHeight or 16) + 2) - return note -end -P.AddDurationColorsLink = AddDurationColorsLink - --- Create a proxy table that maps flat key access to an indicator instance --- Fallback chain: instance value → global defaults → TYPE_DEFAULTS -local function CreateInstanceProxy(auraName, indicatorID) - -- Pin the pool at creation (B2): proxies are minted per effect card, and - -- the card's tab is the record's pool — a callback firing after a tab - -- switch must keep reading/writing the ORIGINAL pool. - local otherPool = IsOtherTab() - local function pool() return otherPool and GetOtherAuras() or GetSpecAuras() end - -- Resolve current type to expose TYPE_DEFAULTS to GUI:CreateColorPicker's - -- Default button. Type changes rebuild the panel (RefreshPage) which makes - -- a fresh proxy, so stashing at construction time is safe. - local _inst = GetIndicatorByID(auraName, indicatorID, pool()) - local _typeDefaults = _inst and TYPE_DEFAULTS[_inst.type] or nil - return setmetatable({ _skipOverrideIndicators = true, __dfDefaults = _typeDefaults }, { - __index = function(_, k) - local inst = GetIndicatorByID(auraName, indicatorID, pool()) - if inst then - local val = inst[k] - if val ~= nil then return val end - end - -- Fall back to global defaults for applicable keys - local fallback - if inst and inst.type then - local gdMap = GLOBAL_DEFAULT_MAP[inst.type] - if gdMap then - local gdKey = gdMap[k] - if gdKey then - local adDB = GetAuraDesignerDB() - local gd = adDB and adDB.defaults - if gd and gd[gdKey] ~= nil then fallback = gd[gdKey] end - end - end - -- Then fall back to TYPE_DEFAULTS - if fallback == nil then - local defaults = TYPE_DEFAULTS[inst.type] - if defaults then fallback = defaults[k] end - end - end - -- Copy-on-read: if fallback is a table, copy it into the instance - -- so that sub-key mutations (e.g. proxy.color.r = 1) persist - if type(fallback) == "table" and inst then - local copy = {} - for fk, fv in pairs(fallback) do copy[fk] = fv end - inst[k] = copy - return copy - end - return fallback - end, - __newindex = function(_, k, v) - local inst = GetIndicatorByID(auraName, indicatorID, pool()) - if not inst then return end - inst[k] = v - if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end - RefreshLiveFramesThrottled() - end, - }) -end -P.CreateInstanceProxy = CreateInstanceProxy - --- Create a proxy table that maps flat key access to nested aura config -local function CreateProxy(auraName, typeKey) - local defaults = TYPE_DEFAULTS[typeKey] - -- Pin the pool at creation (B2) — see CreateInstanceProxy. - local otherPool = IsOtherTab() - local function pool() return otherPool and GetOtherAuras() or GetSpecAuras() end - -- Expose defaults to GUI:CreateColorPicker so its Default button can resolve - -- AD-specific keys (color/expiringColor/etc.) that aren't in PartyDefaults. - return setmetatable({ _skipOverrideIndicators = true, __dfDefaults = defaults }, { - __index = function(_, k) - local auraCfg = pool()[auraName] - if auraCfg and auraCfg[typeKey] then - local val = auraCfg[typeKey][k] - if val ~= nil then return val end - end - -- Fall back to defaults for missing keys - local fallback = defaults and defaults[k] or nil - -- Copy-on-read: if fallback is a table, copy it into the config - -- so that sub-key mutations (e.g. proxy.color.r = 1) persist - if type(fallback) == "table" then - local typeCfg = EnsureTypeConfig(auraName, typeKey, pool()) - local copy = {} - for fk, fv in pairs(fallback) do copy[fk] = fv end - typeCfg[k] = copy - return copy - end - return fallback - end, - __newindex = function(_, k, v) - local typeCfg = EnsureTypeConfig(auraName, typeKey, pool()) - typeCfg[k] = v - if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end - RefreshLiveFramesThrottled() - end, - }) -end -P.CreateProxy = CreateProxy - --- Create a proxy for the aura-level config (priority, expiring) -local function CreateAuraProxy(auraName) - -- Pin the pool at creation (B2) — see CreateInstanceProxy. - local otherPool = IsOtherTab() - local function pool() return otherPool and GetOtherAuras() or GetSpecAuras() end - return setmetatable({ _skipOverrideIndicators = true }, { - __index = function(_, k) - local auraCfg = pool()[auraName] - if auraCfg then return auraCfg[k] end - return nil - end, - __newindex = function(_, k, v) - local auraCfg = EnsureAuraConfig(auraName, pool()) - auraCfg[k] = v - if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end - RefreshLiveFramesThrottled() - end, - }) -end -P.CreateAuraProxy = CreateAuraProxy - --- ============================================================ --- WARNING BADGE --- Some auras have underlying API limitations that mean multiple --- spells collapse into a single indicator. Entries in Config.lua --- with a `warningKey` get a small yellow triangle overlay on their --- spell icon and collapsible header, with a tooltip explaining why. --- ============================================================ - -local WARNING_TEXTURE = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning.tga" - --- Resolve a warningKey to localized tooltip text. Keys are defined --- here rather than Config.lua so they can go through L[] without --- load-order concerns. -local function GetWarningText(warningKey) - if not warningKey then return nil end - local L = DF.L or setmetatable({}, { __index = function(_, k) return k end }) - if warningKey == "HolyArmamentsMerge" then - return L["Holy Bulwark and Sacred Weapon share the same aura signature and cannot be tracked separately. Both buffs will trigger this single indicator."] - end - return nil -end - --- Look up the warningKey for a given aura name in the current spec. -local function GetAuraWarningKey(specKey, auraName) - local specList = DF.AuraDesigner.TrackableAuras and DF.AuraDesigner.TrackableAuras[specKey] - if not specList then return nil end - for _, entry in ipairs(specList) do - if entry.name == auraName then return entry.warningKey end - end - return nil -end -P.GetAuraWarningKey = GetAuraWarningKey - --- Attach (or refresh) a warning triangle badge on the given region. --- host: parent Frame the badge is attached to (must be a Frame). --- warnKey: config warning key; nil hides the badge. --- opts: optional table with: --- point -- default "TOPRIGHT" --- relativeTo -- default host --- relativePoint -- default "TOPRIGHT" --- offsetX/Y -- default 3, 3 --- size -- default 16 --- color -- { r, g, b } default red { 1.0, 0.25, 0.25 } -local function AttachWarningBadge(host, warnKey, opts) - if not host then return end - local badge = host.dfWarningBadge - if not warnKey then - if badge then badge:Hide() end - return - end - local tooltipText = GetWarningText(warnKey) - if not tooltipText then - if badge then badge:Hide() end - return - end - - if not badge then - badge = CreateFrame("Frame", nil, host) - badge:SetFrameLevel(host:GetFrameLevel() + 5) - local tex = badge:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints(badge) - tex:SetTexture(WARNING_TEXTURE) - badge.texture = tex - badge:SetScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = self.tooltipText or "" }) - end) - badge:SetScript("OnLeave", function() GUI:HideTooltip() end) - host.dfWarningBadge = badge - end - - opts = opts or {} - local size = opts.size or 16 - local color = opts.color or { 1.0, 0.25, 0.25 } - badge:SetSize(size, size) - badge:ClearAllPoints() - badge:SetPoint( - opts.point or "TOPRIGHT", - opts.relativeTo or host, - opts.relativePoint or "TOPRIGHT", - opts.offsetX or 3, - opts.offsetY or 3 - ) - badge.texture:SetVertexColor(color[1], color[2], color[3]) - badge.tooltipText = tooltipText - badge:Show() -end -P.AttachWarningBadge = AttachWarningBadge - --- Ad-hoc add-by-ID auras (picker "Add" with an ID the SpellDB doesn't know) --- are stored under the key "#" — the name IS the identity, so the --- record needs no side table and survives reload/profile export for free. --- Returns the embedded numeric spell ID, or nil for normal aura names. -local function AdHocSpellID(auraName) - if type(auraName) ~= "string" then return nil end - local id = auraName:match("^#(%d+)$") - return id and tonumber(id) or nil -end - --- Configured ad-hoc "#" auras are never in the trackable pool, so pool-driven --- pickers (layout-group "Add aura", frame-effect "Add Trigger") can't offer them. --- Returns a NEW list = the given trackable list plus one aura-info-shaped entry per --- configured ad-hoc aura (live-resolved display name, pool-grey accent), sorted by --- spell ID for a stable order. Never mutates `list` — GetTrackableAuras caches it. -local AD_HOC_COLOR = { 0.62, 0.62, 0.62 } -local function WithConfiguredAdHocAuras(list, spec) - local out = {} - for i, info in ipairs(list) do out[i] = info end - local adHoc = {} - -- Pool-routed: the Other tab's group picker offers the OTHER pool's - -- configured ad-hoc auras (never creates it — read accessor). - for auraName in pairs(CurrentAuraPool(spec)) do - local id = AdHocSpellID(auraName) - if id then tinsert(adHoc, { name = auraName, id = id }) end - end - sort(adHoc, function(a, b) return a.id < b.id end) - for _, e in ipairs(adHoc) do - local display = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(e.id) - tinsert(out, { - name = e.name, - display = display or e.name, - color = AD_HOC_COLOR, - spellID = e.id, - class = "ALL", -- picker grid sections by class; ad-hoc ids aren't class spells - }) - end - return out -end -P.WithConfiguredAdHocAuras = WithConfiguredAdHocAuras - --- Get spell icon texture for an aura --- Uses static texture IDs to avoid C_Spell.GetSpellTexture returning --- the wrong icon when talent choice nodes replace a spell. -local function GetAuraIcon(specKey, auraName) - -- Static icon table — always returns the correct icon regardless of talents - local icons = DF.AuraDesigner.IconTextures - if icons and icons[auraName] then - return icons[auraName] - end - -- Fallback to dynamic API for any aura not in the static table - local spellIDs = DF.AuraDesigner.SpellIDs - local specIDs = spellIDs and specKey and spellIDs[specKey] - local spellID = specIDs and specIDs[auraName] - if not spellID or spellID == 0 then - -- Ad-hoc "#" keys resolve directly to their embedded spell ID - spellID = AdHocSpellID(auraName) - end - if not spellID or spellID == 0 then - -- SpellDB fallback (all-spec support): auras with no curated Config - -- entry resolve by name through the FilterRegistry SpellDB - local R = DF.FilterRegistry - local rec = R and R.GetSpellByName and R:GetSpellByName(auraName) - spellID = rec and rec.id - end - if not spellID or spellID == 0 then return nil end - if C_Spell and C_Spell.GetSpellTexture then - return C_Spell.GetSpellTexture(spellID) - elseif GetSpellTexture then - return GetSpellTexture(spellID) - end - return nil -end -P.GetAuraIcon = GetAuraIcon - --- (CountActiveEffects removed — uncalled since the v4 flat-card redesign; --- reclaimed for the file-scope 200-locals ceiling.) - --- ============================================================ --- MULTI-TRIGGER HELPERS --- Functions for managing trigger auras on frame-level effects --- ============================================================ - --- Get triggers for a frame effect (returns owning aura name in a table if no explicit triggers) -local function GetFrameEffectTriggers(auraName, typeKey) - local auraCfg = CurrentAuraPool()[auraName] - local typeCfg = auraCfg and auraCfg[typeKey] - if typeCfg and typeCfg.triggers then - return typeCfg.triggers - end - return { auraName } -- Default: just the owning aura -end -P.GetFrameEffectTriggers = GetFrameEffectTriggers - --- Add a trigger aura to a frame effect. `pool` (optional) pins the target --- pool — the floating trigger picker captures it at OPEN time so a dropdown --- surviving a tab switch can't write the trigger into the wrong pool. -local function AddFrameEffectTrigger(auraName, typeKey, triggerName, pool) - local typeCfg = EnsureTypeConfig(auraName, typeKey, pool) - if not typeCfg.triggers then - typeCfg.triggers = { auraName } -- Initialize with owner - end - -- Check not already present - for _, t in ipairs(typeCfg.triggers) do - if t == triggerName then return end - end - tinsert(typeCfg.triggers, triggerName) -end -P.AddFrameEffectTrigger = AddFrameEffectTrigger - --- Remove a trigger aura from a frame effect (minimum 1 trigger required) -local function RemoveFrameEffectTrigger(auraName, typeKey, triggerName) - local auraCfg = CurrentAuraPool()[auraName] - local typeCfg = auraCfg and auraCfg[typeKey] - if not typeCfg or not typeCfg.triggers or #typeCfg.triggers <= 1 then return end - for i, t in ipairs(typeCfg.triggers) do - if t == triggerName then - tremove(typeCfg.triggers, i) - break - end - end -end -P.RemoveFrameEffectTrigger = RemoveFrameEffectTrigger - --- ============================================================ --- SHARED SPELL PICKER STATE --- Every AD picking context (add indicator, layout-group adds, --- triggers) opens the shared spell database picker --- (FilterRegistry/SpellPicker.lua) over the right panel; the --- open helpers live below the tab system (OpenADPicker). --- ============================================================ - --- (S.adPickerHandle declared on the state table) -S.adPickerDirty = false -- an add landed while the picker stayed open - -- (the tab behind it is stale; rebuilt on close) - --- Close the shared picker if it's open. Called on sub-tab, main-tab and --- spec switches: the picker's records/handlers capture the pool and effect --- from open time, so a stale open picker must not linger. -local function CloseADPicker() - if S.adPickerHandle and S.adPickerHandle:IsOpen() then - S.adPickerHandle:Close() - end -end -P.CloseADPicker = CloseADPicker - --- ============================================================ --- LAYOUT GROUP HELPERS --- Functions for managing layout groups --- ============================================================ - --- State for expanded layout group cards -local expandedGroups = {} -P.expandedGroups = expandedGroups - --- expandedGroups key for a layout group id on the ACTIVE tab: raw numeric id --- on My Buffs (legacy keys, kept as-is), "othergroup:" on Other Buffs. --- The two id counters overlap, so the string prefix keeps expansion state --- per-pool ("dgroup:" is the Debuffs form in the same table). -local function GroupExpandKey(groupID) - if IsOtherTab() then return "othergroup:" .. groupID end - return groupID -end -P.GroupExpandKey = GroupExpandKey - --- Find which layout group (if any) an indicator belongs to — searched in the --- ACTIVE tab's group store, so an other-pool indicator only matches other-pool --- groups (a same-named spec-pool member with a matching indicatorID can never --- false-match across pools). -local function GetIndicatorLayoutGroup(auraName, indicatorID) - local groups = CurrentLayoutGroups() - for _, group in ipairs(groups) do - if group.members then - for _, member in ipairs(group.members) do - if member.auraName == auraName and member.indicatorID == indicatorID then - return group - end - end - end - end - return nil -end -P.GetIndicatorLayoutGroup = GetIndicatorLayoutGroup - --- (GetUngroupedIndicators removed — uncalled since the group picker moved to --- the full spell-picker "group" mode; reclaimed for the 200-locals ceiling.) - --- Create a new layout group. kind: nil/"members" = classic member arranger --- (legacy records carry no kind); "filter" = a container-backed group linked to --- registry filters (stable preset keys / custom ids in filterSelection) with --- uniform per-group styling (iconSize / maxIcons on top of the shared layout). -local function CreateLayoutGroup(name, kind) - local adDB = GetAuraDesignerDB() - if not adDB then return nil end - -- Pool-routed: the Other Buffs tab creates into the flat spec-independent - -- store (born lazily HERE — the first add) with its own id counter. - local groups, id - if IsOtherTab() then - groups = GetOtherLayoutGroups(true) -- the first add creates the store - if not adDB.nextOtherLayoutGroupID then adDB.nextOtherLayoutGroupID = 1 end - id = adDB.nextOtherLayoutGroupID - adDB.nextOtherLayoutGroupID = id + 1 - else - groups = GetSpecLayoutGroups() - if not adDB.nextLayoutGroupID then adDB.nextLayoutGroupID = 1 end - id = adDB.nextLayoutGroupID - adDB.nextLayoutGroupID = id + 1 - end - local group = { - id = id, - name = name or NextGroupName(groups, (kind == "filter") and "Filter Group" or "Group"), - anchor = "TOPLEFT", - offsetX = 0, - offsetY = 0, - growDirection = "RIGHT_DOWN", - iconsPerRow = 8, - spacing = 2, - } - if kind == "filter" then - group.kind = "filter" - group.filterSelection = { presets = {}, customs = {} } - group.iconSize = 24 - -- Filter groups start compact (4×4); member groups keep the shared 8. - -- Creation-time values only — existing saved groups are untouched. - group.iconsPerRow = 4 - group.maxIcons = 4 - else - group.members = {} - end - tinsert(groups, group) - return group -end -P.CreateLayoutGroup = CreateLayoutGroup - --- Delete a layout group by ID (from the ACTIVE tab's store; the member- --- indicator cascade removes from the active pool via RemoveIndicatorInstance's --- CurrentAuraPool routing — members always live in their group's pool) -local function DeleteLayoutGroup(groupID) - local groups = CurrentLayoutGroups() - for i, group in ipairs(groups) do - if group.id == groupID then - -- Delete all member indicators when deleting the group - if group.members then - for _, member in ipairs(group.members) do - RemoveIndicatorInstance(member.auraName, member.indicatorID) - end - end - tremove(groups, i) - break - end - end - expandedGroups[GroupExpandKey(groupID)] = nil -end -P.DeleteLayoutGroup = DeleteLayoutGroup - --- Delete a debuff category group by ID (C2). No member indicators to cascade --- (category groups own no placed indicators). The caller runs the FULL --- structural chain afterwards — the group's claimed categories return to the --- main debuff bar. Card keys are "dgroup:" (see S.BuildDebuffGroupsTab). -local function DeleteDebuffGroup(groupID) - local groups = DebuffGroupsRead() - for i, group in ipairs(groups) do - if group.id == groupID then - tremove(groups, i) - break - end - end - expandedGroups["dgroup:" .. groupID] = nil -end -P.DeleteDebuffGroup = DeleteDebuffGroup - --- Find a layout group by ID (active tab's store) -local function GetLayoutGroupByID(groupID) - local groups = CurrentLayoutGroups() - for _, group in ipairs(groups) do - if group.id == groupID then return group end - end - return nil -end -P.GetLayoutGroupByID = GetLayoutGroupByID - --- Add a member to a layout group -local function AddGroupMember(groupID, auraName, indicatorID) - local group = GetLayoutGroupByID(groupID) - if not group then return end - if not group.members then group.members = {} end - -- Check not already in this group - for _, m in ipairs(group.members) do - if m.auraName == auraName and m.indicatorID == indicatorID then return end - end - tinsert(group.members, { auraName = auraName, indicatorID = indicatorID }) -end -P.AddGroupMember = AddGroupMember - --- Remove a member from a layout group -local function RemoveGroupMember(groupID, auraName, indicatorID) - local group = GetLayoutGroupByID(groupID) - if not group or not group.members then return end - for i, m in ipairs(group.members) do - if m.auraName == auraName and m.indicatorID == indicatorID then - tremove(group.members, i) - break - end - end -end -P.RemoveGroupMember = RemoveGroupMember - --- Swap two members in a layout group (for reordering) -local function SwapGroupMembers(groupID, idx1, idx2) - local group = GetLayoutGroupByID(groupID) - if not group or not group.members then return end - if idx1 < 1 or idx1 > #group.members or idx2 < 1 or idx2 > #group.members then return end - group.members[idx1], group.members[idx2] = group.members[idx2], group.members[idx1] -end -P.SwapGroupMembers = SwapGroupMembers - --- Anchor dot pool (populated during CreateFramePreview, used by drag system) -local anchorDots = {} -P.anchorDots = anchorDots - --- Anchor point positions relative to the mock frame -local ANCHOR_POSITIONS = { - TOPLEFT = { x = 0, y = 0, ax = "TOPLEFT", ay = "TOPLEFT" }, - TOP = { x = 0.5, y = 0, ax = "TOP", ay = "TOP" }, - TOPRIGHT = { x = 1, y = 0, ax = "TOPRIGHT", ay = "TOPRIGHT" }, - LEFT = { x = 0, y = 0.5, ax = "LEFT", ay = "LEFT" }, - CENTER = { x = 0.5, y = 0.5, ax = "CENTER", ay = "CENTER" }, - RIGHT = { x = 1, y = 0.5, ax = "RIGHT", ay = "RIGHT" }, - BOTTOMLEFT = { x = 0, y = 1, ax = "BOTTOMLEFT", ay = "BOTTOMLEFT" }, - BOTTOM = { x = 0.5, y = 1, ax = "BOTTOM", ay = "BOTTOM" }, - BOTTOMRIGHT = { x = 1, y = 1, ax = "BOTTOMRIGHT", ay = "BOTTOMRIGHT" }, -} -P.ANCHOR_POSITIONS = ANCHOR_POSITIONS - --- ============================================================ --- FRAME REFERENCES (populated during build) --- Declared early so drag/indicator/effects code can capture them --- ============================================================ --- (S.mainFrame declared on the state table) --- (S.leftPanel declared on the state table) --- (S.rightPanel declared on the state table) --- (S.enableBanner declared on the state table) --- (S.framePreview declared on the state table) --- (S.dragHintText declared on the state table) - --- Layout anchors — stored during build --- (S.contentRightInset declared on the state table) --- (S.origY_framePreview declared on the state table) - --- (The DF_AURA_DESIGNER_RESET_GLOBAL popup was retired with the editing-banner --- "Reset to Global" button — the preset dropdown's "Inherit (Global)" entry now --- clears a layout's Aura Designer preset override.) - --- ============================================================ --- UI STATE (v4 redesign — tabbed right panel) --- ============================================================ -S.activeTab = "effects" -- "effects" | "layout" | "global" -S.activeFilter = "all" -- Filter chip state -local expandedCards = {} -- { ["placed:AuraName#1"] = true, ["frame:border:AuraName"] = true } -P.expandedCards = expandedCards - --- Tab system frame references --- (S.tabBar declared on the state table) -local tabButtons = {} -- { effects = btn, layout = btn, global = btn } -P.tabButtons = tabButtons -local mainTabButtons = {} -- { my = btn, other = btn } (B2 main pool tab strip) -P.mainTabButtons = mainTabButtons --- (S.specDropdown declared on the state table) --- (S.specDropdownUpdate declared on the state table) --- (S.tabContentFrame declared on the state table) --- (S.tabScrollFrame declared on the state table) -local effectCardPool = {} -- Reusable card frames -P.effectCardPool = effectCardPool - --- ============================================================ --- EFFECTS LIST DATA COLLECTION --- Gathers all effects across all auras into a flat list for --- the new Effects tab. Replaces the old per-aura view. --- ============================================================ - -local FRAME_LEVEL_TYPE_KEYS = { "border", "healthbar", "background", "nametext", "healthtext", "sound" } - --- Remove an ad-hoc "#" aura's config entry once it holds no effects at --- all (empty/absent indicators array AND no frame-level type keys). Ad-hoc --- auras exist ONLY through their effects — they are never in the trackable --- pool — so an emptied config would linger in the profile forever as a --- phantom entry (group/trigger pickers, SavedVariables growth). Curated --- auras deliberately keep today's linger behavior. Forward-declared above --- RemoveIndicatorInstance, which calls it after every removal. -S.CleanupAdHocAura = function(auraName) - if not AdHocSpellID(auraName) then return end - local auras = CurrentAuraPool() - local auraCfg = auras and auras[auraName] - if type(auraCfg) ~= "table" then return end - if auraCfg.indicators and #auraCfg.indicators > 0 then return end - for _, typeKey in ipairs(FRAME_LEVEL_TYPE_KEYS) do - if auraCfg[typeKey] ~= nil then return end - end - auras[auraName] = nil -end - --- Effect-type display labels. Same file-scope-vs-overlay timing issue as the --- option tables near the top of this file: build them in a registered refresh --- fn so they pick up the active locale. -S.FRAME_LEVEL_LABELS = {} -S.PLACED_TYPE_LABELS = {} - -local function RefreshEffectLabels() - S.FRAME_LEVEL_LABELS = { - border = L["Border"], - healthbar = L["Health Bar"], - background = L["Background"], - nametext = L["Name Text"], - healthtext = L["Health Text"], - sound = L["Sound Alert"], - } - - S.PLACED_TYPE_LABELS = { - icon = L["Icon"], - square = L["Square"], - bar = L["Bar"], - } -end - -RefreshEffectLabels() -DF:RegisterLocaleRefresh(RefreshEffectLabels) - -local BADGE_COLORS = { - icon = { r = 0.36, g = 0.72, b = 0.94 }, -- Blue - square = { r = 0.51, g = 0.86, b = 0.51 }, -- Green - bar = { r = 0.94, g = 0.71, b = 0.24 }, -- Orange - border = { r = 0.80, g = 0.50, b = 0.80 }, -- Purple - healthbar = { r = 0.94, g = 0.31, b = 0.31 }, -- Red - background = { r = 0.40, g = 0.55, b = 0.65 }, -- Slate - nametext = { r = 0.72, g = 0.72, b = 0.94 }, -- Light blue - healthtext = { r = 0.72, g = 0.72, b = 0.94 }, -- Light blue - sound = { r = 0.94, g = 0.76, b = 0.24 }, -- Gold/yellow -} -P.BADGE_COLORS = BADGE_COLORS - --- Collect all configured effects into a flat, sorted list --- Returns: { { source="placed"|"frame", auraName, typeKey, ... }, ... } -local function CollectAllEffects() - local effects = {} - - local spec = ResolveSpec() - local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) - -- Build display name lookup (only auras belonging to current spec) - local displayNames = {} - if trackable then - for _, info in ipairs(trackable) do - displayNames[info.name] = info.display - end - end - - local isOther = IsOtherTab() - for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do - -- My Buffs: only show effects for auras belonging to the current spec. - -- Ad-hoc "#" auras (picker add-by-ID) always belong — they are - -- never in the trackable pool, so resolve their display name live. - -- Other Buffs: the pool is spec-independent — every record shows - -- (names are SpellDB names or ad-hoc keys; resolve display live). - local adHocID = AdHocSpellID(auraName) - local displayName - if isOther then - displayName = OtherPoolDisplayName(auraName) - else - displayName = displayNames[auraName] - if not displayName and adHocID then - displayName = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(adHocID) or auraName - end - end - if type(auraCfg) == "table" and displayName then - -- Placed indicators - if auraCfg.indicators then - for _, indicator in ipairs(auraCfg.indicators) do - tinsert(effects, { - source = "placed", - auraName = auraName, - displayName = displayName, - indicatorID = indicator.id, - typeKey = indicator.type, - config = indicator, - anchor = indicator.anchor or "CENTER", - }) - end - end - - -- Frame-level effects (current per-aura model) - for _, typeKey in ipairs(FRAME_LEVEL_TYPE_KEYS) do - if auraCfg[typeKey] then - tinsert(effects, { - source = "frame", - auraName = auraName, - displayName = displayName, - typeKey = typeKey, - config = auraCfg[typeKey], - }) - end - end - end - end - - -- Sort: newest first (reverse by insertion order — higher IDs first for placed) - sort(effects, function(a, b) - -- Placed before frame-level - if a.source ~= b.source then - return a.source == "placed" - end - -- Within placed: higher indicatorID first (newest) - if a.source == "placed" and b.source == "placed" then - return (a.indicatorID or 0) > (b.indicatorID or 0) - end - -- Within frame-level: alphabetical by type - return a.typeKey < b.typeKey - end) - - return effects -end -P.CollectAllEffects = CollectAllEffects - --- Check if a specific aura + type combo already has a placed indicator -local function IsAuraTypePlaced(auraName, typeKey) - local auraCfg = CurrentAuraPool()[auraName] - if not auraCfg or not auraCfg.indicators then return false end - for _, indicator in ipairs(auraCfg.indicators) do - if indicator.type == typeKey then return true end - end - return false -end -P.IsAuraTypePlaced = IsAuraTypePlaced - --- ============================================================ --- DRAG AND DROP SYSTEM --- Modeled after DandersCDM's ghost-based drag pattern: --- Ghost frame (TOOLTIP strata, EnableMouse false) follows cursor --- Anchor dots act as drop targets via OnEnter/OnLeave --- OnUpdate frame polls IsMouseButtonDown for drop detection --- ============================================================ - -local dragState = { - isDragging = false, - auraName = nil, -- Which aura is being dragged - auraInfo = nil, -- Full aura info table - specKey = nil, -- Spec key for icon lookup - dropAnchor = nil, -- Currently hovered anchor name - moveIndicatorID = nil, -- Set when re-dragging an existing placed indicator - indicatorType = nil, -- "icon" | "square" | "bar" — type to create on drop -} -P.dragState = dragState - -S.dragGhost = nil -S.dragUpdateFrame = nil - -local function CreateDragGhost() - if S.dragGhost then return S.dragGhost end - - S.dragGhost = CreateFrame("Frame", "DFAuraDesignerDragGhost", UIParent, "BackdropTemplate") - S.dragGhost:SetSize(36, 36) - S.dragGhost:SetFrameStrata("TOOLTIP") - S.dragGhost:SetFrameLevel(1000) - S.dragGhost:EnableMouse(false) -- KEY: mouse events pass through to drop targets - S.dragGhost:Hide() - - if not S.dragGhost.SetBackdrop then Mixin(S.dragGhost, BackdropTemplateMixin) end - DF.GUI:CreateElementBackdrop(S.dragGhost, { - edgeSize = 2, - bgColor = { 0.05, 0.05, 0.05, 0.9 }, - }) - - -- Spell icon - local icon = S.dragGhost:CreateTexture(nil, "ARTWORK") - icon:SetPoint("TOPLEFT", 3, -3) - icon:SetPoint("BOTTOMRIGHT", -3, 3) - icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) - S.dragGhost.icon = icon - - -- Name label under ghost - local label = S.dragGhost:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("TOP", S.dragGhost, "BOTTOM", 0, -2) - label:SetTextColor(1, 1, 1, 0.8) - S.dragGhost.label = label - - return S.dragGhost -end - --- (S.EndDrag declared on the state table) - --- Start a move-drag for an existing placed indicator (the ghost + --- cursor-following + anchor-dot system; new-placement drags died with the --- old spell-picker cards -- indicators are placed via the shared picker now). -local function StartMoveDrag(auraName, indicatorID, specKey) - if dragState.isDragging then return end - - dragState.isDragging = true - dragState.auraName = auraName - dragState.moveIndicatorID = indicatorID - dragState.specKey = specKey - dragState.dropAnchor = nil - - -- Build minimal auraInfo for hints - local adDB = GetAuraDesignerDB() - local displayName = auraName - if IsOtherTab() then - -- Other-pool keys are SpellDB names / ad-hoc ids — resolve display live - displayName = OtherPoolDisplayName(auraName) - else - local auraList = Adapter and Adapter:GetTrackableAuras(ResolveSpec()) - if auraList then - for _, info in ipairs(auraList) do - if info.name == auraName then - dragState.auraInfo = info - displayName = info.display or auraName - break - end - end - end - end - - -- Setup ghost - local ghost = CreateDragGhost() - local tc = GetThemeColor() - ghost:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) - - local iconTex = GetAuraIcon(specKey, auraName) - if iconTex then - ghost.icon:SetTexture(iconTex) - else - ghost.icon:SetColorTexture(0.3, 0.3, 0.3, 1) - end - ghost.label:SetText(displayName) - ghost:Show() - - -- Show drag hint - if S.dragHintText then - S.dragHintText:SetText(format(L["Drop on an anchor point to move %s"], displayName)) - S.dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) - end - - -- Show and enlarge all anchor dots - local dc = GetThemeColor() - for _, dotFrame in pairs(anchorDots) do - dotFrame:Show() - dotFrame.dot:SetSize(10, 10) - dotFrame.dot:SetColorTexture(dc.r, dc.g, dc.b, 0.5) - end - - -- Start cursor following - if not S.dragUpdateFrame then - S.dragUpdateFrame = CreateFrame("Frame") - end - S.dragUpdateFrame:SetScript("OnUpdate", function() - if not dragState.isDragging then - S.dragUpdateFrame:Hide() - return - end - - local x, y = GetCursorPosition() - local scale = UIParent:GetEffectiveScale() - local cursorX, cursorY = x / scale, y / scale - - ghost:ClearAllPoints() - ghost:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cursorX + 10, cursorY - 10) - - if not IsMouseButtonDown("LeftButton") then - S.EndDrag() - end - end) - S.dragUpdateFrame:Show() -end - -S.EndDrag = function() - if not dragState.isDragging then return end - - local auraName = dragState.auraName - local dropAnchor = dragState.dropAnchor - local moveID = dragState.moveIndicatorID - local indicatorType = dragState.indicatorType or "icon" - - -- Clear state - dragState.isDragging = false - dragState.auraName = nil - dragState.auraInfo = nil - dragState.specKey = nil - dragState.dropAnchor = nil - dragState.moveIndicatorID = nil - dragState.indicatorType = nil - - -- Hide ghost - if S.dragGhost then S.dragGhost:Hide() end - - -- Stop cursor following - if S.dragUpdateFrame then - S.dragUpdateFrame:Hide() - S.dragUpdateFrame:SetScript("OnUpdate", nil) - end - - -- Clear drag hint - if S.dragHintText then - S.dragHintText:SetText("") - end - - -- Hide anchor dots (only visible during drag) - local dc = GetThemeColor() - for _, dotFrame in pairs(anchorDots) do - dotFrame:Hide() - dotFrame.dot:SetSize(6, 6) - dotFrame.dot:SetColorTexture(dc.r, dc.g, dc.b, 0.3) - end - - -- Process the drop - if auraName and dropAnchor then - if moveID then - -- Move existing indicator to the new anchor - local inst = GetIndicatorByID(auraName, moveID) - if inst then - inst.anchor = dropAnchor - inst.offsetX = 0 - inst.offsetY = 0 - end - else - -- Create a new indicator instance at the dropped anchor - local inst = CreateIndicatorInstance(auraName, indicatorType) - if inst then - inst.anchor = dropAnchor - end - end - - -- Expand the new indicator card in the Effects tab (pool-prefixed key) - local auraCfg = CurrentAuraPool()[auraName] - local lastInst = auraCfg and auraCfg.indicators and auraCfg.indicators[#auraCfg.indicators] - if lastInst then - local cardKey = "placed:" .. PoolKeyPrefix() .. auraName .. "#" .. lastInst.id - expandedCards[cardKey] = true - end - end - - -- Refresh everything - DF:AuraDesigner_RefreshPage() -end - --- ============================================================ --- PLACED INDICATORS ON PREVIEW --- Small icons/squares/bars rendered at anchor positions --- ============================================================ - -local placedIndicators = {} -P.placedIndicators = placedIndicators - --- Set by ClearPlacedIndicators, consumed by the S.page's RefreshStates: tab --- switching re-enters the S.page through GUI RefreshCached -> RefreshStates ONLY --- (the builder is cache-skipped), and RefreshStates early-outs when the S.page --- dimensions are unchanged — so a canvas cleared by the OnHide hook stayed --- blank on every same-size revisit (the first revisit only worked because the --- dims baseline was captured pre-layout). The flag lets the dimension guard --- distinguish "nothing changed" from "cleared and awaiting repaint". -S.placedCleared = false - -local function ClearPlacedIndicators() - -- Preview border ANIMATIONS must be stopped explicitly on every frame this - -- pass hides: the drivers are external (Border.lua's shared UIParent-hosted - -- driver ticks secretRect borders EVEN WHILE HIDDEN), so hiding without - -- StopAnimation leaves orphaned ticks — the same mandatory teardown the - -- live container runs (_teardownContainer). Group placeholder slots carry - -- their sample-frame children's borders too. - local B = DF.Border - local function stopAnims(f) - if not B then return end - if f.dfBorder then B:StopAnimation(f.dfBorder) end - local samples = f.sampleFrames - if samples then - for i = 1, #samples do - if samples[i].dfBorder then B:StopAnimation(samples[i].dfBorder) end - end - end - end - for _, ind in ipairs(placedIndicators) do - stopAnims(ind) - ind:Hide() - end - wipe(placedIndicators) - - -- Clean up AD indicator maps on the mockFrame - if S.framePreview and S.framePreview.mockFrame then - local mock = S.framePreview.mockFrame - if mock.dfADPreviewSlots then - for _, rec in pairs(mock.dfADPreviewSlots) do - stopAnims(rec.slot) - rec.slot:Hide() - -- The alert slot carries no border (its style is duration-only), so it has - -- no animation to stop — only the indicator's slot needs stopAnims. - if rec.alertSlot then rec.alertSlot:Hide() end - end - end - mock.dfAD = nil - end - S.placedCleared = true -end -P.ClearPlacedIndicators = ClearPlacedIndicators - --- ============================================================ --- NATIVE PREVIEW SLOTS (12.1) --- Each placed indicator on the canvas is a PREVIEW SLOT: a plain frame --- styled + painted by the container engine's own styler with the exact --- config the live factory builds (Factory:BuildPreviewConfig) — the --- preview IS the live rendering. Slots are pooled per instanceKey and --- recreated when the structural sig changes (regions are create-only). --- ============================================================ - -local function RenderPreviewIndicator(mockFrame, spec, auraName, info, indicator, effectiveConfig, instanceKey) - local Factory = DF.AuraDesigner and DF.AuraDesigner.Factory - local AC = DF.AuraContainer - if not (Factory and Factory.BuildPreviewConfig and AC and AC.StylePreviewSlot) then return nil end - - -- The aura's real spell ID lives in the spell-pool config (the trackable- - -- aura info entries don't carry IDs) — this drives the previewed icon and - -- identity, exactly like the live container's filter map. - local specIDs = DF.AuraDesigner.SpellIDs and DF.AuraDesigner.SpellIDs[spec] - local spellID = specIDs and specIDs[auraName] - if type(spellID) == "table" then spellID = spellID[1] end - if not spellID then - -- Ad-hoc "#" keys resolve directly — mirror BuildADIdentityFilters - spellID = AdHocSpellID(auraName) - end - if not spellID then - -- SpellDB fallback (all-spec support) — mirror BuildADIdentityFilters - local R = DF.FilterRegistry - local rec = R and R.GetSpellByName and R:GetSpellByName(auraName) - spellID = rec and rec.id - end - -- Resolve the global defaults with the Factory's OWN resolver so the canvas preview and - -- the live render can never disagree on the fallback chain. (Level/strata come along in - -- the table but the preview ignores them: the canvas is standalone, not layered over a - -- unit frame, so there is nothing for a z-order band to mean there.) - local defs = Factory.ResolveDefaults and Factory.ResolveDefaults(GetAuraDesignerDB()) - local cfg, sig = Factory:BuildPreviewConfig(mockFrame, effectiveConfig, indicator.type or "icon", spellID, defs) - if not (cfg.testEntries and cfg.testEntries[1]) then - -- No resolvable spell ID: synthesize an entry from the configured art so - -- the paint can never fall back to the generic curated pool. - cfg.testEntries = { { name = (info and info.display) or auraName } } - end - do - local e = cfg.testEntries[1] - if not e.icon then e.icon = GetAuraIcon(spec, auraName) end - e.duration = 15 - e.stacks = (indicator.type ~= "bar") and 3 or 0 - end - - local store = mockFrame.dfADPreviewSlots - if not store then store = {}; mockFrame.dfADPreviewSlots = store end - local rec = store[instanceKey] - if rec and rec.sig ~= sig then - -- Abandoned slot: stop its border animation — the external shared - -- driver ticks secretRect borders even while hidden (Border.lua). - if rec.slot.dfBorder and DF.Border then DF.Border:StopAnimation(rec.slot.dfBorder) end - rec.slot:Hide() - if rec.alertSlot then rec.alertSlot:Hide() end - rec = nil - end - if not rec then - rec = { slot = CreateFrame("Frame", nil, mockFrame), sig = sig } - store[instanceKey] = rec - end - local slot = rec.slot - AC.StylePreviewSlot(slot, cfg) - - local lay = cfg.layout or {} - if lay.sizeX then - slot:SetSize(lay.sizeX, lay.sizeY or lay.sizeX) - else - local s = lay.size or 24 - slot:SetSize(s, s) - end - slot:SetScale(lay.scale or 1) - slot:ClearAllPoints() - slot:SetPoint(lay.anchor or "TOPLEFT", mockFrame, lay.anchor or "TOPLEFT", lay.offsetX or 0, lay.offsetY or 0) - slot:SetFrameStrata(mockFrame:GetFrameStrata()) - slot:SetFrameLevel(mockFrame:GetFrameLevel() + 8) - AC.PaintPreviewSlot(slot, cfg, 1) - slot:Show() - - -- Expiry Alert sample: a SECOND PREVIEW SLOT laid over the indicator's, styled and - -- painted by the same StylePreviewSlot/PaintPreviewSlot pipeline as the indicator - -- itself and the group blocks. cfg.alertPreview is a whole slot config from - -- Factory:BuildAlertPreviewConfig, carrying the SAME style table the live companion - -- renders — so the FontString, its font, anchor, offsets, alpha and holder level are - -- all container-engine output here, not decisions made in this file. - -- - -- This used to hand-build a FontString and hand-set its layering. Two separate bugs - -- came out of that in one day: the live companion moved and the canvas could not - -- follow, because the canvas was never DERIVED from it, only numerically matched. The - -- rule is the one that already covers test mode — a preview may differ in DATA, never - -- in RENDERING. - -- - -- nil (hidden) whenever the live companion would also be absent: alertSlotStyle is the - -- single gate for both, so "off", "no formatter API" and "show-when-missing" can no - -- longer mean different things on the canvas than they do live. - local ap = cfg.alertPreview - if ap then - local ah = rec.alertSlot - if not ah then - ah = CreateFrame("Frame", nil, slot) - rec.alertSlot = ah - end - -- Share the indicator's OWN entry, so both slots count down the same aura for the - -- same 15s rather than two entries that happen to agree. cfg.testEntries is - -- rewritten above when no spell ID resolved, hence taking it here and not at build. - ap.testEntries = cfg.testEntries - AC.StylePreviewSlot(ah, ap) - -- SetAllPoints AFTER styling: styleButton_regions sizes the slot from the layout, - -- and the alert must coincide with the indicator's rect exactly, the way the live - -- companion's invisible button does. - ah:ClearAllPoints() - ah:SetAllPoints(slot) - ah:SetFrameStrata(slot:GetFrameStrata()) - ah:SetFrameLevel(slot:GetFrameLevel() + (Factory.ALERT_ROW_LIFT or 13)) - -- Reuse the indicator slot's duration object (PaintPreviewSlot armed it just above) - -- so the reveal reacts to the very timer it sits on, not a second one. - AC.PaintPreviewSlot(ah, ap, 1, slot._dfTestDurObj) - ah:Show() - elseif rec.alertSlot then - rec.alertSlot:Hide() - end - return slot -end - -local function WirePreviewIndicator(slot, capturedAura, capturedID, spec) - slot:EnableMouse(true) - if slot.SetMouseClickEnabled then slot:SetMouseClickEnabled(true) end - slot:SetScript("OnMouseUp", function(_, button) - if dragState.isDragging then return end - if button == "RightButton" then - -- Don't delete grouped indicators (managed by layout group) - if not GetIndicatorLayoutGroup(capturedAura, capturedID) then - RemoveIndicatorInstance(capturedAura, capturedID) - DF:AuraDesigner_RefreshPage() - -- Structural change: same full refresh as the effect-card ✕ — - -- the container must rebuild and the buff-row dedup union - -- shrinks (deleted = no longer tracked). - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end - elseif button == "LeftButton" then - -- Collapse all cards and expand only the clicked one. The preview - -- renders the ACTIVE tab's pool only, so PoolKeyPrefix() at click - -- time matches the slot's pool (B1 key scheme). - local cardKey = "placed:" .. PoolKeyPrefix() .. capturedAura .. "#" .. capturedID - wipe(expandedCards) - expandedCards[cardKey] = true - S.activeTab = "effects" - DF:AuraDesigner_RefreshPage() - end - end) - slot:RegisterForDrag("LeftButton") - slot:SetScript("OnDragStart", function() - -- Don't drag grouped indicators (position managed by layout group) - if GetIndicatorLayoutGroup(capturedAura, capturedID) then return end - StartMoveDrag(capturedAura, capturedID, spec) - end) -end - --- Representative preview icons per debuff category. Category membership is --- Blizzard-secret at runtime, so the editor shows iconic stand-ins instead of --- real members. Entry encoding: NEGATIVE numbers are spell IDs (icon resolved --- live via C_Spell.GetSpellTexture — all evergreen player abilities, so they --- stay valid across patches); positive numbers would be literal texture --- FileDataIDs; strings are texture paths. Never shown as live auras — the --- placeholder desaturates them (example affordance). -local DEBUFF_CATEGORY_SAMPLES = { - boss = { "Interface\\TargetingFrame\\UI-RaidTargetingIcon_8", -348 }, -- skull marker, Immolate - role = { -6343, -113746 }, -- Thunder Clap, Mystic Touch - priority = { -980, -34914 }, -- Agony, Vampiric Touch - crowdControl = { -118, -6770, -5782 }, -- Polymorph, Sap, Fear - raid = { -589, -1943 }, -- Shadow Word: Pain, Rupture - dispellable = { -339, -51514 }, -- Entangling Roots, Hex - _order = { "boss", "role", "priority", "crowdControl", "raid", "dispellable" }, -} - --- Example icons for a container-backed group's placeholder block, or nil when --- the group has nothing to sample (caller falls back to the outline-only --- style). maxDefault mirrors the DrawGroupPlaceholderSlot default so both --- compute the same slot count. --- * Filter groups: sample REAL spells from the linked filters — resolve the --- selection (same R:ResolveSelection the factory uses; preview-path only, --- so no cache needed) and take the first N canonical ids in sorted order --- (deterministic — the preview is stable across refreshes). An empty or --- dangling selection resolves to kind "all" / an empty map → nil. --- * Debuff groups (records carry .selection): cycle the selected categories' --- representative samples across all N slots in _order. -local function GroupSampleIcons(group, maxDefault) - local n = max(1, tonumber(group.maxIcons) or maxDefault) - local icons = {} - if group.selection then - -- Debuff category group: round-robin the selected categories - local cats = {} - for _, key in ipairs(DEBUFF_CATEGORY_SAMPLES._order) do - if group.selection[key] then tinsert(cats, DEBUFF_CATEGORY_SAMPLES[key]) end - end - if #cats == 0 then return nil end - for i = 1, n do - local samples = cats[(i - 1) % #cats + 1] - local entry = samples[(floor((i - 1) / #cats) % #samples) + 1] - if type(entry) == "number" and entry < 0 then - entry = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(-entry)) or 134400 - end - icons[i] = entry - end - return icons - end - if group.kind ~= "filter" then return nil end - local R = DF.FilterRegistry - if not R then return nil end - local res = R:ResolveSelection(group.filterSelection, false) - if res.kind == "all" then return nil end -- empty/dangling selection - -- Canonical ids: variant/alt ids collapse onto their record's id; raw ids - -- from custom filters (no registry record) sample as themselves. - local ids, seen = {}, {} - if res.kind == "include" then - for id in pairs(res.map) do - local rec = R.ByID and R.ByID[id] - local cid = rec and rec.id or id - if not seen[cid] then seen[cid] = true; tinsert(ids, cid) end - end - else -- "exclude" (Uncategorised): complement of the known registry - for _, rec in ipairs(R.Spells) do - if not res.map[rec.id] then tinsert(ids, rec.id) end - end - end - if #ids == 0 then return nil end - sort(ids) - for i = 1, min(n, #ids) do - local rec = R.ByID and R.ByID[ids[i]] - if rec then - local _, icon = R:GetSpellDisplay(rec) - icons[i] = icon - else - icons[i] = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(ids[i])) or 134400 - end - end - return icons -end - --- Shared labeled-outline placeholder for container-backed groups (filter --- groups on My Buffs, debuff category groups on Debuffs): their contents are --- Blizzard-filled at runtime (secret visibility, registry/category-driven), --- so the editor canvas can't preview real member icons. Draw an outline block --- at the group's anchor/offset sized to its footprint (iconSize × --- min(maxIcons, iconsPerRow) columns, wrapped rows), filled with EXAMPLE --- icons (`icons` array from GroupSampleIcons; nil/empty = outline only) so --- size/spacing/grow/per-row edits are visible. Pooled per group id in the --- caller's pool table (separate pools — the two id counters can collide); --- returns the slot for the placedIndicators list. -local function DrawGroupPlaceholderSlot(mockFrame, pool, group, wrapDefault, maxDefault, icons) - local slot = pool[group.id] - if not slot then - slot = CreateFrame("Frame", nil, mockFrame, "BackdropTemplate") - -- The group-name label rides its own high-level child so it stays - -- legible above the sample FRAMES below (a parent's own regions - -- would draw underneath child frames regardless of layer). - slot.labelHost = CreateFrame("Frame", nil, slot) - slot.labelHost:SetAllPoints(slot) - slot.label = slot.labelHost:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(slot.label, 8, "OUTLINE") - slot.label:SetPoint("CENTER", 0, 0) - pool[group.id] = slot - end - local iconSize = max(8, tonumber(group.iconSize) or 24) - local maxIcons = max(1, tonumber(group.maxIcons) or maxDefault) - local wrap = max(1, tonumber(group.iconsPerRow) or wrapDefault) - local spacing = tonumber(group.spacing) or 2 - local cols = min(maxIcons, wrap) - local rows = floor((maxIcons - 1) / wrap) + 1 - slot:SetSize(max(cols * iconSize + (cols - 1) * spacing, 10), - max(rows * iconSize + (rows - 1) * spacing, 10)) - ApplyBackdrop(slot, {r = 0.91, g = 0.66, b = 0.25, a = 0.10}, - {r = 0.91, g = 0.66, b = 0.25, a = 0.8}) - slot.label:SetText(group.name) - slot.label:SetTextColor(0.91, 0.66, 0.25) - slot.label:SetWidth(slot:GetWidth() - 4) - slot.label:SetMaxLines(2) - -- Pin the block's grow-corner at the group's anchor point so it - -- extends in the grow direction (mirror the container's flow origin). - local p, s = strsplit("_", group.growDirection or "RIGHT_DOWN") - local vert, horiz = "TOP", "LEFT" - for _, d in ipairs({ p, s }) do - if d == "UP" then vert = "BOTTOM" - elseif d == "DOWN" then vert = "TOP" - elseif d == "LEFT" then horiz = "RIGHT" - elseif d == "RIGHT" then horiz = "LEFT" - elseif d == "CENTER" then horiz = "" end - end - local selfPoint = (horiz == "") and vert or (vert .. horiz) - slot:ClearAllPoints() - slot:SetPoint(selfPoint, mockFrame, group.anchor or "TOPLEFT", - group.offsetX or 0, group.offsetY or 0) - slot:SetFrameStrata(mockFrame:GetFrameStrata()) - slot:SetFrameLevel(mockFrame:GetFrameLevel() + 8) - -- Above the sample frames' text holders (slot+1 base + duration/stack - -- holder offsets) and border art, so the group name always reads. - slot.labelHost:SetFrameLevel(slot:GetFrameLevel() + 15) - - -- EXAMPLE ICONS inside the block geometry (nil icons = outline only). - -- Each sample is a pooled child FRAME styled + painted by the factory's - -- own preview pipeline (Factory:BuildGroupPreviewConfig from group.style → - -- AuraContainer.StylePreviewSlot/PaintPreviewSlot — the exact path the - -- placed indicators' canvas preview uses), so the group's Appearance - -- settings (border incl. animation, cooldown swipe, duration text with - -- colour-by-time/hide-above, stack count) render on the samples. Regions - -- are create-only (the live Rebuild rule): when the structural sig moves - -- (duration/stacks/border on-off, format key), drop the pool and - -- re-create — same recreate-on-sig idiom as RenderPreviewIndicator. - -- Fully rebound both directions: a group whose samples empty out - -- (filters unlinked, categories deselected) returns to the bare outline - -- (extras hidden), and vice versa. Icons fill the block's grid from its - -- pinned grow-corner (centered rows when the primary direction is - -- CENTER), so grow/wrap edits read directionally. Desaturation is the - -- "example, not live" affordance; style-less groups render through the - -- same pipeline with the default style (= today's live rendering). - local Factory = DF.AuraDesigner and DF.AuraDesigner.Factory - local AC = DF.AuraContainer - local cfg, sig - if icons and Factory and Factory.BuildGroupPreviewConfig and AC and AC.StylePreviewSlot then - cfg, sig = Factory:BuildGroupPreviewConfig(mockFrame, group) - end - local framePool = slot.sampleFrames - if framePool and slot.sampleSig ~= sig then - -- Structural style change: abandon the old frames (hidden — the - -- RenderPreviewIndicator idiom; regions can't be removed in place). - -- Stop their border animations first: the external shared driver - -- ticks secretRect borders even while hidden (Border.lua), so an - -- abandoned animated sample would tick forever. - for i = 1, #framePool do - local f = framePool[i] - if f.dfBorder and DF.Border then DF.Border:StopAnimation(f.dfBorder) end - f:Hide() - end - framePool = nil - slot.sampleFrames = nil - end - slot.sampleSig = sig - if not framePool then framePool = {}; slot.sampleFrames = framePool end - local count = (cfg and icons) and min(#icons, maxIcons) or 0 - if count > 0 then - -- Curated per-slot entries: the sample's art plus a static duration/ - -- stack sample (the paint staggers the countdown per index and runs - -- it through the group's own formatter — colour-by-time buckets and - -- the hide-above blank band mirror live). - local entries = {} - for i = 1, count do - entries[i] = { icon = icons[i], duration = 15, stacks = 3 } - end - cfg.testEntries = entries - end - local step = iconSize + spacing - local xSign = (horiz == "RIGHT") and -1 or 1 - local ySign = (vert == "TOP") and -1 or 1 - for i = 1, count do - local f = framePool[i] - if not f then - f = CreateFrame("Frame", nil, slot) - framePool[i] = f - end - AC.StylePreviewSlot(f, cfg) -- sizes the frame from cfg.layout.size (= iconSize) - f:ClearAllPoints() - local col = (i - 1) % wrap - local row = floor((i - 1) / wrap) - if horiz == "" then - -- CENTER primary: rows centered on the block's vert edge - local lastRow = floor((count - 1) / wrap) - local iconsInRow = (row == lastRow) and (((count - 1) % wrap) + 1) or wrap - f:SetPoint(vert, slot, vert, - (col - (iconsInRow - 1) / 2) * step, ySign * row * step) - else - local corner = vert .. horiz - f:SetPoint(corner, slot, corner, xSign * col * step, ySign * row * step) - end - AC.PaintPreviewSlot(f, cfg, i) - if f.dfIcon then f.dfIcon:SetDesaturated(true) end -- example affordance - f:Show() - end - for i = count + 1, #framePool do - local f = framePool[i] - -- Same external-driver rule as above; a re-shown extra restyles via - -- StylePreviewSlot → Border:Apply, which restarts its animation. - if f.dfBorder and DF.Border then DF.Border:StopAnimation(f.dfBorder) end - f:Hide() - end - - slot:Show() - return slot -end - -local function RefreshPlacedIndicators() - ClearPlacedIndicators() - S.placedCleared = false -- this pass IS the repaint (after Clear re-armed the flag) - if not S.framePreview then return end - - local mockFrame = S.framePreview.mockFrame - if not mockFrame then return end - - local adDB = GetAuraDesignerDB() - local isOther = IsOtherTab() - local isDebuffs = IsDebuffTab() - local spec = ResolveSpec() - -- Other Buffs and the Debuffs tab are spec-independent: render even with - -- no resolvable spec. - if not spec and not (isOther or isDebuffs) then return end - - local auraList = (not isOther and not isDebuffs) and spec and Adapter and Adapter:GetTrackableAuras(spec) or nil - if not auraList and not (isOther or isDebuffs) then return end - - -- Build lookup - local infoLookup = {} - if auraList then - for _, info in ipairs(auraList) do - infoLookup[info.name] = info - end - end - - -- Build layout group position lookup for preview - -- In preview all indicators are visible, so compute positions for all members. - -- The preview renders the ACTIVE tab's pool, so the group pass reads the - -- active tab's group store (spec-keyed on My Buffs, the flat other store on - -- Other Buffs — read-only, never creates it); Debuffs has neither. Keys carry - -- the B1 pool prefix so they line up with the instanceKeys built below. - local keyPrefix = PoolKeyPrefix() - local groupPositions = {} -- "auraName#indicatorID" → { anchor, offsetX, offsetY } - local specGroups = isDebuffs and EMPTY_POOL or CurrentLayoutGroups() - for _, group in ipairs(specGroups) do - if group.members then - for memberIdx, member in ipairs(group.members) do - local key = keyPrefix .. member.auraName .. "#" .. member.indicatorID - -- Compute position based on group settings - local activeIdx = memberIdx - 1 -- 0-based - -- Need to find the indicator's size to compute step (members - -- live in the same pool as their group — the active pool) - local memberCfg = CurrentAuraPool(spec)[member.auraName] - local indCfg = nil - if memberCfg and memberCfg.indicators then - for _, ind in ipairs(memberCfg.indicators) do - if ind.id == member.indicatorID then - indCfg = ind - break - end - end - end - local size = (indCfg and indCfg.size) or (adDB.defaults and adDB.defaults.iconSize) or 24 - local scale = (indCfg and indCfg.scale) or (adDB.defaults and adDB.defaults.iconScale) or 1.0 - local step = (size * scale) + (group.spacing or 2) - - local growth = group.growDirection or "RIGHT" - local primary, secondary = strsplit("_", growth) - if not secondary then - secondary = (primary == "RIGHT" or primary == "LEFT") and "DOWN" or "RIGHT" - end - local wrap = group.iconsPerRow or 8 - if wrap <= 0 then wrap = 1 end - local totalCount = #group.members - local col = activeIdx % wrap - local row = floor(activeIdx / wrap) - local function gOff(d, s) - if d == "LEFT" then return -s, 0 elseif d == "RIGHT" then return s, 0 - elseif d == "UP" then return 0, s elseif d == "DOWN" then return 0, -s end - return 0, 0 - end - local sX, sY = gOff(secondary, step) - local oX, oY - if primary == "CENTER" then - local iconsInRow = wrap - local lastRow = floor((totalCount - 1) / wrap) - if row == lastRow then - iconsInRow = ((totalCount - 1) % wrap) + 1 - end - local centerOff = -((iconsInRow - 1) * step) / 2 - if sX ~= 0 then - oX = (group.offsetX or 0) + (row * sX) - oY = (group.offsetY or 0) + centerOff + (col * step) - else - oX = (group.offsetX or 0) + centerOff + (col * step) - oY = (group.offsetY or 0) + (row * sY) - end - else - local pX, pY = gOff(primary, step) - oX = (group.offsetX or 0) + (col * pX) + (row * sX) - oY = (group.offsetY or 0) + (col * pY) + (row * sY) - end - groupPositions[key] = { - anchor = group.anchor or "TOPLEFT", - offsetX = oX, - offsetY = oY, - } - end - end - end - - -- FILTER GROUP PLACEHOLDERS (My Buffs / Other Buffs): labeled outline - -- blocks via the shared helper above. Pooled per group id — SEPARATE pool - -- table per tab (the two id counters can collide, mirror the dgroup pool); - -- eye-hidden groups draw nothing. Wrap/max defaults 8 match the classic - -- member-group layout. - do - local fgPoolKey = isOther and "dfADOtherFilterGroupSlots" or "dfADFilterGroupSlots" - local fgPool = mockFrame[fgPoolKey] - if not fgPool then fgPool = {}; mockFrame[fgPoolKey] = fgPool end - for _, group in ipairs(specGroups) do - if group.kind == "filter" and group.enabled ~= false then - tinsert(placedIndicators, - DrawGroupPlaceholderSlot(mockFrame, fgPool, group, 8, 8, - GroupSampleIcons(group, 8))) - end - end - end - - -- DEBUFF GROUP PLACEHOLDERS (C2): Debuffs tab only — the preview renders - -- the ACTIVE tab's surfaces, so these blocks never mix with the buff - -- pools' indicators. Separate pool table (dgroup ids come from their own - -- counter and could collide with filter-group ids). Wrap/max defaults 4 - -- mirror CreateDebuffGroup and the factory's buildFilterGroupLayout(_, 4). - -- Read-only: visiting the tab must not create adDB.debuffGroups. - if isDebuffs then - local dgPool = mockFrame.dfADDebuffGroupSlots - if not dgPool then dgPool = {}; mockFrame.dfADDebuffGroupSlots = dgPool end - for _, group in ipairs(DebuffGroupsRead()) do - if group.enabled ~= false then - tinsert(placedIndicators, - DrawGroupPlaceholderSlot(mockFrame, dgPool, group, 4, 4, - GroupSampleIcons(group, 4))) - end - end - end - - -- Iterate all configured auras, find placed indicator instances. - -- Ad-hoc "#" auras are never in the trackable pool — render them with - -- info=nil (RenderPreviewIndicator resolves their id/icon by pattern). - -- Other-pool records render with info=nil + nil identity spec (SpellDB / - -- ad-hoc resolution, mirroring the factory). Slot keys carry the B1 - -- "other:" prefix so the two pools' slots can't collide in the store. - -- Hidden indicators (eye toggle, enabled == false) don't render — same as live. - -- (keyPrefix hoisted above the group-position pass — same value.) - local idSpec = isOther and nil or spec - for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do - local info = infoLookup[auraName] - if type(auraCfg) == "table" and (isOther or info or AdHocSpellID(auraName)) and auraCfg.indicators then - for _, indicator in ipairs(auraCfg.indicators) do - if indicator.enabled ~= false then - local instanceKey = keyPrefix .. auraName .. "#" .. indicator.id - local capturedAura = auraName - local capturedID = indicator.id - - -- Apply layout group position override if applicable - local effectiveConfig = indicator - local gPos = groupPositions[instanceKey] - if gPos then - effectiveConfig = setmetatable({ - anchor = gPos.anchor, - offsetX = gPos.offsetX, - offsetY = gPos.offsetY, - }, { __index = indicator }) - end - - local slot = RenderPreviewIndicator(mockFrame, idSpec, auraName, info, indicator, effectiveConfig, instanceKey) - if slot then - WirePreviewIndicator(slot, capturedAura, capturedID, idSpec) - tinsert(placedIndicators, slot) - end - end - end - end - end -end -P.RefreshPlacedIndicators = RefreshPlacedIndicators - --- ============================================================ --- PREVIEW EFFECTS --- Apply frame-level effects (border, healthbar, text, alpha) --- for the currently selected aura on the mock frame --- ============================================================ - -local function GetOrCreatePreviewCustomBorder(mockFrame, key) - if not mockFrame.dfPreviewCustomBorders then - mockFrame.dfPreviewCustomBorders = {} - end - local pool = mockFrame.dfPreviewCustomBorders - if pool[key] then return pool[key] end - -- Stage 5.4: preview uses DF.Border (mirrors the runtime), below the - -- shared preview border (+5). - pool[key] = DF.Border:New(mockFrame, { frameLevelOffset = 4, layer = "OVERLAY" }) - return pool[key] -end - -local function RefreshPreviewEffects() - if not S.framePreview then return end - local mockFrame = S.framePreview.mockFrame - if not mockFrame then return end - - -- Reset shared border overlay (Stage 5.4: DF.Border — hide edges + anim) - if S.framePreview.borderOverlay then - DF.Border:Apply(S.framePreview.borderOverlay, { enabled = false }) - end - -- Reset custom border overlays - if mockFrame.dfPreviewCustomBorders then - for _, ch in pairs(mockFrame.dfPreviewCustomBorders) do - DF.Border:Apply(ch, { enabled = false }) - end - end - if S.framePreview.healthFill then - S.framePreview.healthFill:SetVertexColor(0.18, 0.80, 0.44, 0.85) - end - if S.framePreview.nameText then - S.framePreview.nameText:SetTextColor(0.18, 0.80, 0.44, 1) - end - if S.framePreview.hpText then - S.framePreview.hpText:SetTextColor(0.87, 0.87, 0.87, 1) - end - mockFrame:SetAlpha(1) - -- Reset the shared single-target elements to their defaults ONCE, before any - -- aura's effects are applied. Previously the background's reset lived in an - -- in-loop `elseif`, so a background-less aura could wipe an earlier aura's - -- background depending on pairs() iteration order — the intermittent - -- "doesn't show on preview" report for Background / Health Bar Color. - if S.framePreview.healthBg then - S.framePreview.healthBg:SetColorTexture(0, 0, 0, 0.4) - end - if S.framePreview.missingHealth then - S.framePreview.missingHealth:SetColorTexture(0, 0, 0, 0.4) - end - - -- Frame-level effects all draw onto the SAME single preview elements (one - -- healthFill / healthBg / nameText / etc.), so when more than one aura - -- configures the same type they conflict. The runtime resolves this by - -- priority (higher number wins; first claim per type — see prioritySort and - -- Indicators:Apply's `if state.X then return end`). Mirror that here so the - -- preview is deterministic instead of pairs()-order-dependent: iterate auras - -- in descending-priority order (tiebreak by name) and apply first-wins per type. - local sortedAuras = {} - for auraName, auraCfg in pairs(CurrentAuraPool()) do - if type(auraCfg) == "table" then -- skip corrupted entries - sortedAuras[#sortedAuras + 1] = { name = auraName, cfg = auraCfg, priority = auraCfg.priority or 5 } - end - end - sort(sortedAuras, function(a, b) - if a.priority ~= b.priority then return a.priority > b.priority end -- higher number = higher priority - return a.name < b.name - end) - - local claimed = {} - for _, entry in ipairs(sortedAuras) do - local auraName, auraCfg = entry.name, entry.cfg - - -- Border effect (Stage 5.4: rendered via DF.Border, mirroring the runtime). - -- Shared borders use a single overlay (first/highest-priority claim wins); - -- custom borders get independent per-aura overlays so multiple can stack. - -- Every type skips hidden blocks (eye toggle, enabled == false) — same as pickWinner. - if auraCfg.border and auraCfg.border.enabled ~= false and auraCfg.border.ShowBorder ~= false then - local spec = DF.Border:BuildSpec(auraCfg.border, "") - spec.animation = nil -- AD border animation retired on 12.1; the preview must match the - -- live render (which strips it at the container chokepoint) so a - -- stuck-on BorderAnimationType from an old profile doesn't animate here. - if not spec.color then spec.color = { r = 1, g = 1, b = 1, a = 1 } end - spec.enabled = true - if auraCfg.border.borderMode == "custom" then - DF.Border:Apply(GetOrCreatePreviewCustomBorder(mockFrame, auraName), spec) - elseif not claimed.border and S.framePreview.borderOverlay then - claimed.border = true - DF.Border:Apply(S.framePreview.borderOverlay, spec) - end - end - - -- Health bar color (first claim wins) - if not claimed.healthbar and auraCfg.healthbar and auraCfg.healthbar.enabled ~= false and S.framePreview.healthFill then - claimed.healthbar = true - local clr = auraCfg.healthbar.color or {r = 1, g = 1, b = 1, a = 1} - local blend = auraCfg.healthbar.blend or 0.5 - if auraCfg.healthbar.mode == "Replace" then - S.framePreview.healthFill:SetVertexColor(clr.r, clr.g, clr.b, clr.a or 1) - else - -- Tint: blend original green with the configured color, scaled by alpha - -- so dragging the colour picker's alpha visibly weakens the tint - -- (matches ApplyHealthBar in Indicators.lua: overlay = blend × alpha). - local effBlend = blend * (clr.a or 1) - local r = 0.18 * (1 - effBlend) + clr.r * effBlend - local g = 0.80 * (1 - effBlend) + clr.g * effBlend - local b = 0.44 * (1 - effBlend) + clr.b * effBlend - S.framePreview.healthFill:SetVertexColor(r, g, b, 1) - -- Tint Entire Bar: paint the same tint hue over the (dark) missing- - -- health region so the preview shows the colour spanning the full bar. - if auraCfg.healthbar.tintWholeBar and S.framePreview.missingHealth then - S.framePreview.missingHealth:SetColorTexture(clr.r * effBlend, clr.g * effBlend, clr.b * effBlend, 0.4 + 0.25 * effBlend) - end - end - end - - -- Background color (first claim wins). Recolours the frame background — shows - -- through the missing-health area, like the runtime overlay behind the bars. - if not claimed.background and auraCfg.background and auraCfg.background.enabled ~= false and S.framePreview.healthBg then - claimed.background = true - local clr = auraCfg.background.color or {r = 1, g = 1, b = 1, a = 1} - if auraCfg.background.mode == "Replace" then - local a = clr.a or 1 - S.framePreview.healthBg:SetColorTexture(clr.r, clr.g, clr.b, 0.4 + 0.6 * a) - else - local blend = (auraCfg.background.blend or 0.5) * (clr.a or 1) - -- Blend the configured colour over the dark default background. - S.framePreview.healthBg:SetColorTexture(clr.r * blend, clr.g * blend, clr.b * blend, 0.4 + 0.4 * blend) - end - end - - -- Name text color (first claim wins) - if not claimed.nametext and auraCfg.nametext and auraCfg.nametext.enabled ~= false and S.framePreview.nameText then - claimed.nametext = true - local clr = auraCfg.nametext.color or {r = 1, g = 1, b = 1, a = 1} - S.framePreview.nameText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) - end - - -- Health text color (first claim wins) - if not claimed.healthtext and auraCfg.healthtext and auraCfg.healthtext.enabled ~= false and S.framePreview.hpText then - claimed.healthtext = true - local clr = auraCfg.healthtext.color or {r = 1, g = 1, b = 1, a = 1} - S.framePreview.hpText:SetTextColor(clr.r, clr.g, clr.b, clr.a or 1) - end - - end -- for _, entry in sortedAuras -end -P.RefreshPreviewEffects = RefreshPreviewEffects - --- ============================================================ --- LIGHTWEIGHT PREVIEW REFRESH --- Re-applies indicator settings to existing preview frames without --- destroying/recreating them. Called from proxy __newindex so every --- slider drag tick, checkbox toggle, or dropdown change is live. --- ============================================================ - -S.RefreshPreviewLightweight = function() - if not S.framePreview or not S.framePreview.mockFrame then return end - local mockFrame = S.framePreview.mockFrame - - local adDB = GetAuraDesignerDB() - local isOther = IsOtherTab() - local isDebuffs = IsDebuffTab() - local spec = ResolveSpec() - if not spec and not (isOther or isDebuffs) then return end - - -- Build layout group position lookup (same as RefreshPlacedIndicators: - -- active tab's group store over the active pool, pool-prefixed keys; - -- Debuffs has no member groups so the pass reads empty there) - local keyPrefix = PoolKeyPrefix() - local groupPositions = {} - local specGroups2 = isDebuffs and EMPTY_POOL or CurrentLayoutGroups() - for _, group in ipairs(specGroups2) do - if group.members then - for memberIdx, member in ipairs(group.members) do - local key = keyPrefix .. member.auraName .. "#" .. member.indicatorID - local activeIdx = memberIdx - 1 - local memberCfg = CurrentAuraPool(spec)[member.auraName] - local indCfg = nil - if memberCfg and memberCfg.indicators then - for _, ind in ipairs(memberCfg.indicators) do - if ind.id == member.indicatorID then indCfg = ind; break end - end - end - local size = (indCfg and indCfg.size) or (adDB.defaults and adDB.defaults.iconSize) or 24 - local scale = (indCfg and indCfg.scale) or (adDB.defaults and adDB.defaults.iconScale) or 1.0 - local step = (size * scale) + (group.spacing or 2) - -- Grid-aware layout matching RefreshPlacedIndicators / ComputeGroupOffset - local growth = group.growDirection or "RIGHT" - local primary, secondary = strsplit("_", growth) - if not secondary then - secondary = (primary == "RIGHT" or primary == "LEFT") and "DOWN" or "RIGHT" - end - local wrap = group.iconsPerRow or 8 - if wrap <= 0 then wrap = 1 end - local totalCount = #group.members - local col = activeIdx % wrap - local row = floor(activeIdx / wrap) - local function gOff(d, s) - if d == "LEFT" then return -s, 0 elseif d == "RIGHT" then return s, 0 - elseif d == "UP" then return 0, s elseif d == "DOWN" then return 0, -s end - return 0, 0 - end - local sX, sY = gOff(secondary, step) - local oX, oY - if primary == "CENTER" then - local iconsInRow = wrap - local lastRow = floor((totalCount - 1) / wrap) - if row == lastRow then - iconsInRow = ((totalCount - 1) % wrap) + 1 - end - local centerOff = -((iconsInRow - 1) * step) / 2 - if sX ~= 0 then - oX = (group.offsetX or 0) + (row * sX) - oY = (group.offsetY or 0) + centerOff + (col * step) - else - oX = (group.offsetX or 0) + centerOff + (col * step) - oY = (group.offsetY or 0) + (row * sY) - end - else - local pX, pY = gOff(primary, step) - oX = (group.offsetX or 0) + (col * pX) + (row * sX) - oY = (group.offsetY or 0) + (col * pY) + (row * sY) - end - groupPositions[key] = { anchor = group.anchor or "TOPLEFT", offsetX = oX, offsetY = oY } - end - end - end - - -- Re-apply placed indicator instances using current settings - -- (hidden indicators skipped — RenderPreviewIndicator would resurrect their - -- slot; keyPrefix hoisted above the group-position pass — same value) - local idSpec = isOther and nil or spec - for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do - if type(auraCfg) == "table" and auraCfg.indicators then - for _, indicator in ipairs(auraCfg.indicators) do - if indicator.enabled ~= false then - local instanceKey = keyPrefix .. auraName .. "#" .. indicator.id - - -- Apply layout group position override if applicable - local effectiveConfig = indicator - local gPos = groupPositions[instanceKey] - if gPos then - effectiveConfig = setmetatable({ - anchor = gPos.anchor, offsetX = gPos.offsetX, offsetY = gPos.offsetY, - }, { __index = indicator }) - end - - -- Restyle/repaint through the shared preview slot (recreated only - -- when the structural sig changes; cosmetic changes restyle in place). - local infoLW = nil - if not isOther and Adapter and Adapter.GetTrackableAuras then - for _, ti in ipairs(Adapter:GetTrackableAuras(spec)) do - if ti.name == auraName then infoLW = ti; break end - end - end - local slot = RenderPreviewIndicator(mockFrame, idSpec, auraName, infoLW, indicator, effectiveConfig, instanceKey) - if slot then - WirePreviewIndicator(slot, auraName, indicator.id, idSpec) - end - end - end - end - end - - -- Also refresh frame-level preview effects (border, healthbar color, text colors, alpha) - RefreshPreviewEffects() -end - --- ============================================================ --- INDICATOR TYPE WIDGET BUILDER --- (Tile strip removed in v4 redesign) --- ============================================================ - - --- layoutGroup: optional layout group table; if set, anchor/offset controls are replaced with a note --- indicatorID: optional indicator ID for placed indicators (used by Copy From) -local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOffset, layoutGroup, indicatorID) - local proxy = optProxy or CreateProxy(auraName, typeKey) - local contentWidth = width or 248 - -- widgets[] entries are {widget, height} so the reflow path can use - -- group.calculatedHeight (current after a LayoutChildren) while - -- non-group widgets fall back to the stored at-build-time height. - local widgets = {} - local startY = 10 + (yOffset or 0) -- top padding + optional offset - local totalHeight = startY - - -- P4.7 (12.1 GUI status overlays): collected here so the block pass at the - -- end of BuildTypeContent can frost the effect-settings groups the factory - -- can't (yet) drive, WITHOUT touching the trigger tags above (built into - -- `parent` before this function runs — the working "which aura" layer). - -- swmCheck is captured for the surgical Show-When-Missing block. See block pass below. - local swmCheck, wholeBarCheck, swmGroup, durColorByTimeCtl - - local function AddWidget(widget, height) - widget:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -totalHeight) - if widget.SetWidth then widget:SetWidth(contentWidth - 10) end - tinsert(widgets, { widget = widget, height = height or 30 }) - totalHeight = totalHeight + (height or 30) - end - - -- Reflow all widgets in this BuildTypeContent's stack. When a group's - -- LayoutChildren updates its own height (e.g. Border's animation - -- widgets show/hide), the siblings below were anchored at FIXED y - -- positions based on the old height — they stay put, causing overlap - -- (group grew) or large gap (group shrank). Walk the list re-anchor - -- each widget at the running total height, reading the current - -- group.calculatedHeight for groups so the new layout flows correctly. - -- The host container's height is updated too so any parent scroll - -- range stays accurate. - parent.dfAD_ReflowWidgets = function() - local y = startY - for _, entry in ipairs(widgets) do - local w = entry.widget - local h - if w.calculatedHeight then - -- SettingsGroup tracks its current height after LayoutChildren. - h = w.calculatedHeight - else - h = entry.height - end - w:ClearAllPoints() - w:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -y) - y = y + h - end - parent:SetHeight(y) - end - - local function AddGroup(header, buildFn, showSummary) - local group = GUI:CreateSettingsGroup(parent, contentWidth - 10, { - collapsible = true, - showSummary = showSummary or false, - }) - group.padding = 10 -- match the main Options groups' inner padding (airier scale) - group:AddWidget(GUI:CreateHeader(parent, header), GUI.RowHeight.sectionHeader) - buildFn(group) - local h = group:LayoutChildren() - AddWidget(group, h) - -- Tag the Show-When-Missing group so the surgical 12.1 block can find it. - if header == L["Show When Missing"] then swmGroup = group end - return group - end - - - -- ── COPY FROM (placed indicators only: icon, square, bar) ── - if indicatorID and (typeKey == "icon" or typeKey == "square" or typeKey == "bar") then - local copyContainer = CreateFrame("Frame", nil, parent) - copyContainer:SetHeight(36) - - local copyLabel = copyContainer:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(copyLabel, 8, "") - copyLabel:SetPoint("TOPLEFT", 1, -1) - copyLabel:SetText(L["COPY APPEARANCE FROM"]) - copyLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local capturedAuraName = auraName - local capturedIndicatorID = indicatorID - local capturedTypeKey = typeKey - - -- Build the list of other placed indicators of the same type as the - -- dropdown's options (rebuilt on each open via optionsFunc so newly-added - -- indicators appear). Keyed by a synthetic id -> a captured source table; - -- picking one copies its appearance. The opener stays "Select indicator..." - -- (it's an action picker, not a value selector), achieved by a customGet - -- that always returns that label string (no matching option key). - local copySources = {} - local function BuildCopyFromOptions() - wipe(copySources) - local isOther = IsOtherTab() - local spec = (not isOther) and ResolveSpec() or nil - local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) - local displayNames = {} - if trackable then - for _, info in ipairs(trackable) do - displayNames[info.name] = info.display - end - end - - -- Sources come from the SAME pool as the destination card (the - -- active tab's) — CopyIndicatorAppearance resolves both ends - -- through the pool-routed GetIndicatorByID. - local sources = {} - for srcAura, auraCfg in pairs(CurrentAuraPool(spec)) do - if type(auraCfg) == "table" and auraCfg.indicators then - for _, ind in ipairs(auraCfg.indicators) do - if ind.type == capturedTypeKey then - -- Skip self - if not (srcAura == capturedAuraName and ind.id == capturedIndicatorID) then - tinsert(sources, { - auraName = srcAura, - displayName = isOther and OtherPoolDisplayName(srcAura) - or displayNames[srcAura] or srcAura, - indicatorID = ind.id, - }) - end - end - end - end - end - sort(sources, function(a, b) return a.displayName < b.displayName end) - - local options = { _order = {} } - for i, src in ipairs(sources) do - local key = "src" .. i - copySources[key] = src - options[key] = { text = src.displayName } - options._order[i] = key - end - return options - end - - local copyDrop = GUI:CreateDropdown( - copyContainer, "", BuildCopyFromOptions(), - nil, nil, nil, - function() return L["Select indicator..."] end, -- customGet (static opener) - function(key) -- customSet (action) - local src = copySources[key] - if src then - CopyIndicatorAppearance(src.auraName, src.indicatorID, capturedAuraName, capturedIndicatorID) - DF:AuraDesigner_RefreshPage() - end - end, - { inline = true, optionsFunc = BuildCopyFromOptions } - ) - copyDrop:SetPoint("TOPLEFT", 0, -12) - copyDrop:SetPoint("RIGHT", copyContainer, "RIGHT", 0, 0) - copyDrop:SetHeight(20) - - AddWidget(copyContainer, 38) - end - - -- Color picker callback shorthand — refreshes both the AD preview and live frames - local function RPL() if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end - - -- Shared Duration Bar section for the icon / square placed indicators — both carry - -- durationBar* keys and the strip hangs off the slot edge regardless of shape. Same - -- control set as the filter-group card and the buff/debuff rows (one shared spec - -- builder, DF:BuildDurationBarSpec, reads these keys). Greys IMPERATIVELY: AD cards - -- have no disableOn/RefreshStates loop (unlike the row pages), so the enable gate - -- AND the curve-mode dimming of Texture/Bar Color are driven by hand from the Enable - -- and Color Mode callbacks. Structural writes (enable/position/height/gap/colorMode) - -- rebuild via the proxy's __newindex → RefreshLiveFramesThrottled, exactly like the - -- Show Duration toggle; only the colour pickers need RPL (sub-table writes skip - -- __newindex — see the Border group's note). - local function AddDurationBarGroup() - AddGroup(L["Duration Bar"], function(g) - local dbWidgets, curveGated = {}, {} - local function UpdateBarGrey() - local on = proxy.durationBarEnabled and true or false - local curve = DF:IsDurationBarCurveMode(proxy.durationBarColorMode) - for i = 1, #dbWidgets do - local w = dbWidgets[i] - local enable = on and not (curveGated[w] and curve) - if w.SetEnabled then w:SetEnabled(enable) - else - w:SetAlpha(enable and 1 or 0.4) - if w.EnableMouse then w:EnableMouse(enable) end - end - end - end - g:AddWidget(GUI:CreateCheckbox(parent, L["Enable Duration Bar"], proxy, "durationBarEnabled", UpdateBarGrey), 28) - local function barChild(widget, h) g:AddWidget(widget, h); dbWidgets[#dbWidgets + 1] = widget; return widget end - barChild(GUI:CreateDropdown(parent, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, proxy, "durationBarPosition"), 54) - barChild(GUI:CreateSlider(parent, L["Height"], 1, 12, 1, proxy, "durationBarHeight"), 54) - barChild(GUI:CreateSlider(parent, L["Gap"], 0, 10, 1, proxy, "durationBarGap"), 54) - barChild(GUI:CreateDropdown(parent, L["Color Mode"], - DF:GetDurationBarColorModes(), - proxy, "durationBarColorMode", UpdateBarGrey), 54) - -- A curve mode brings its own ramp texture and forces a white tint, so these - -- two do nothing while it is selected — grey them (curveGated) when it is. - local texW = barChild(GUI:CreateTextureDropdown(parent, L["Bar Texture"], proxy, "durationBarTexture"), 54) - local colW = barChild(GUI:CreateColorPicker(parent, L["Bar Color"], proxy, "durationBarColor", true, RPL, RPL, true), 28) - curveGated[texW] = true; curveGated[colW] = true - barChild(GUI:CreateColorPicker(parent, L["Background Color"], proxy, "durationBarBGColor", true, RPL, RPL, true), 28) - barChild(GUI:CreateCheckbox(parent, L["Reverse Fill"], proxy, "durationBarReverseFill"), 28) - UpdateBarGrey() - end) - end - - if typeKey == "icon" then - -- Position - AddGroup(L["Position"], function(g) - if layoutGroup then - local groupNote = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - groupNote:SetTextColor(0.91, 0.66, 0.25, 0.8) - groupNote:SetText(format(L["Position managed by: %s"], layoutGroup.name or L["Layout Group"])) - g:AddWidget(groupNote, 18) - else - g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "anchor", function() DF:AuraDesigner_RefreshPage() end), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "offsetX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "offsetY"), 54) - end - end) - -- Appearance - AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateSlider(parent, L["Size"], 8, 64, 1, proxy, "size"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 3.0, 0.05, proxy, "scale"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Alpha"], 0, 1, 0.05, proxy, "alpha"), 54) - g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], proxy, "hideSwipe"), 28) - -- Text-only mode: the icon TEXTURE is hidden, so a border (static OR - -- expiring) would frame nothing. Rebuild the S.page on toggle so the - -- Border group + the expiring-border thickness control hide/show - -- (gated on proxy.hideIcon below) — matching the runtime, which - -- force-disables both borders in this mode. - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Icon (Text Only)"], proxy, "hideIcon", function() - DF:AuraDesigner_RefreshPage() - end), 28) - end) - -- Show When Missing - AddGroup(L["Show When Missing"], function(g) - local desatCb - local function UpdateDesatState() - if not desatCb then return end - if proxy.showWhenMissing then - desatCb:SetAlpha(1) - desatCb:EnableMouse(true) - else - desatCb:SetAlpha(0.4) - desatCb:EnableMouse(false) - end - end - g:AddWidget(GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() - UpdateDesatState() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end), 28) - desatCb = GUI:CreateCheckbox(parent, L["Desaturate When Missing"], proxy, "missingDesaturate", function() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - g:AddWidget(desatCb, 28) - UpdateDesatState() - end) - -- Border (Stage 5.1c — unified controls via CreateBorderControls). - -- Show / Thickness / Inset are the same widgets as before; the helper - -- adds Style / Texture / Color / Gradient / Shadow / BlendMode / - -- Offset / Alpha on top. Animation, classColor, roleColor, and the - -- colorByTime checkbox are deliberately omitted — animation isn't - -- wired through AD's expiring system yet, class/role don't fit aura - -- indicators (the indicator's job is to show aura state, not unit - -- identity), and AD's Expiring section already covers "colour by - -- time remaining" implicitly through its own colour curve. - -- Text-only mode hides the icon texture, so the whole Border group is - -- hidden (the runtime force-disables the border there too). - if not proxy.hideIcon then - AddGroup(L["Border"], function(g) - GUI:CreateBorderControls(g, proxy, "", { - parent = parent, - include = { - inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true, - }, - -- IMPORTANT: AD's per-aura proxy only triggers - -- RefreshLiveFramesThrottled + S.RefreshPreviewLightweight - -- on direct key assignment (proxy.X = v) via __newindex. - -- CreateColorPicker and the Border Alpha slider mutate - -- SUB-TABLE fields (proxy.BorderColor.a = v) which reads - -- through __index then writes to the returned table — no - -- __newindex fires, no refresh runs, and both the live - -- frame AND the AD preview window stay on the pre-edit - -- colour until /reload. - -- - -- RPL (defined above in BuildTypeContent) runs both the - -- preview refresh and the throttled live-frame refresh, so - -- colour-picker / alpha-slider / size-drag updates land - -- everywhere consistently within the 100ms debounce. - fullUpdate = RPL, - lightUpdate = RPL, - lightColors = RPL, - -- refreshStates re-evaluates hideOn on the Border group's - -- widgets and then reflows the sibling groups below in the - -- card body so the Expiring / Duration Text / Stack Count - -- groups slide up or down to track the Border group's new - -- height. Without the reflow, the Border group's internal - -- LayoutChildren updates its own height but the siblings - -- stay at fixed y positions — animation widgets surface - -- and overlap Expiring, or hide and leave a gap above - -- Expiring. dfAD_ReflowWidgets is set on the BuildTypeContent - -- parent and walks the whole widget stack. - refreshStates = function() - g:LayoutChildren() - if parent.dfAD_ReflowWidgets then - parent.dfAD_ReflowWidgets() - end - end, - sizeMin = 1, sizeMax = 5, sizeStep = 1, - }) - end) - end -- if not proxy.hideIcon (text-only hides the Border group) - -- Expiring (moved up next to Border — the border's expiring colour and - -- the per-icon effects all key off the same threshold, so grouping - -- them adjacent reads more naturally than burying Expiring at the - -- bottom of the panel.) - -- Icon: single Expiring Colour, Whole Alpha Pulse + Bounce effects. - -- Duration Text - AddGroup(L["Duration Text"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], proxy, "showDuration"), 28) - -- Icon-sized formats only — Number "14" / Seconds "14s" / Percent "45%"; - -- FULL and the combined "12s (45%)" live on the BAR card, which has the - -- width. Forward-declared UpdateHideAboveState: the dropdown re-greys - -- Hide Above (percent formats can't compose with it), assigned below. - local UpdateHideAboveState - g:AddWidget(GUI:CreateDropdown(parent, L["Duration Format"], { - NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], - _order = { "NUMBER", "SHORT", "PERCENT" }, - }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Duration Font"], proxy, "durationFont"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Duration Scale"], 0.5, 2.0, 0.1, proxy, "durationScale"), 54) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], proxy, "durationOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "durationOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Duration Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "durationAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "durationX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "durationY"), 54) - durColorByTimeCtl = GUI:CreateCheckbox(parent, L["Color by Time Remaining"], proxy, "durationColorByTime") - g:AddWidget(durColorByTimeCtl, 28) - AddDurationColorsLink(g, parent) - g:AddWidget(GUI:CreateColorPicker(parent, L["Duration Text Color"], proxy, "durationColor", true, RPL, RPL, true), 28) - local hideAboveSlider, hideAboveCheck - -- ASSIGNS the forward-declared local from the Duration Format dropdown - -- above (a `local function` here would shadow it and strand the - -- dropdown's callback on nil). Also greys the pair while a - -- percent-family format is picked — Hide Above's seconds threshold - -- can't band a percent-sampled formatter (see GetDurationFormatFields). - UpdateHideAboveState = function() - if not hideAboveSlider then return end - local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) - if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end - if not pctFmt and proxy.durationHideAboveEnabled then - hideAboveSlider:SetAlpha(1) - hideAboveSlider:EnableMouse(true) - else - hideAboveSlider:SetAlpha(0.4) - hideAboveSlider:EnableMouse(false) - end - end - hideAboveCheck = GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", UpdateHideAboveState) - g:AddWidget(hideAboveCheck, 28) - hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold") - g:AddWidget(hideAboveSlider, 54) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent"), 28) - UpdateHideAboveState() - end) - -- Expiration: the frame-anchored Expiry Alert ELEMENT (own section — - -- distinct from the sound "Expire Alert" group on the sound card). - AddGroup(L["Expiration"], function(g) - AddExpiryAlertControls(g, parent, proxy) - end) - -- Stack Count - AddGroup(L["Stack Count"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], proxy, "showStacks"), 28) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Stack Font"], proxy, "stackFont"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Stack Scale"], 0.5, 2.0, 0.1, proxy, "stackScale"), 54) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Stack Outline"], proxy, "stackOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "stackOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Stack Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "stackAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "stackX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "stackY"), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) - end) - -- Duration Bar (native SetDurationBar strip — shared with the square card) - AddDurationBarGroup() - - elseif typeKey == "square" then - -- Position - AddGroup(L["Position"], function(g) - if layoutGroup then - local groupNote = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - groupNote:SetTextColor(0.91, 0.66, 0.25, 0.8) - groupNote:SetText(format(L["Position managed by: %s"], layoutGroup.name or L["Layout Group"])) - g:AddWidget(groupNote, 18) - else - g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "anchor", function() DF:AuraDesigner_RefreshPage() end), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "offsetX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "offsetY"), 54) - end - end) - -- Appearance - AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateSlider(parent, L["Size"], 8, 64, 1, proxy, "size"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 3.0, 0.05, proxy, "scale"), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) - g:AddWidget(GUI:CreateSlider(parent, L["Alpha"], 0, 1, 0.05, proxy, "alpha"), 54) - g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], proxy, "hideSwipe"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Icon (Text Only)"], proxy, "hideIcon"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end), 28) - end) - -- Border (Stage 5.2 — unified controls via CreateBorderControls). - -- Same full toolkit as the icon's base border: Style / Texture / Colour - -- / Gradient / Shadow / Blend / Offset / Alpha + Animation. The - -- square's expiring system tints the FILL (not the border), so the - -- icon's expiring-border overrides are intentionally NOT added here. - AddGroup(L["Border"], function(g) - GUI:CreateBorderControls(g, proxy, "", { - parent = parent, - include = { - inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true, - }, - fullUpdate = RPL, - lightUpdate = RPL, - lightColors = RPL, - refreshStates = function() - g:LayoutChildren() - if parent.dfAD_ReflowWidgets then - parent.dfAD_ReflowWidgets() - end - end, - sizeMin = 1, sizeMax = 5, sizeStep = 1, - }) - end) - -- Expiring (moved up next to Border — matches the icon indicator's - -- panel ordering. Border colour, fill pulsate, alpha pulse, and bounce - -- all key off the same threshold; grouping them adjacent to Border - -- reads more naturally than burying Expiring at the bottom.) - -- Square: separate fill + border Expiring colours (alpha handle edits the - -- BORDER colour); Fill Pulsate + Whole Alpha Pulse + Bounce effects. - -- Duration Text - AddGroup(L["Duration Text"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], proxy, "showDuration"), 28) - -- Icon-sized formats only (see the icon card's Duration Format note). - local UpdateHideAboveState - g:AddWidget(GUI:CreateDropdown(parent, L["Duration Format"], { - NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], - _order = { "NUMBER", "SHORT", "PERCENT" }, - }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Duration Font"], proxy, "durationFont"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Duration Scale"], 0.5, 2.0, 0.1, proxy, "durationScale"), 54) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], proxy, "durationOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "durationOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Duration Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "durationAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "durationX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "durationY"), 54) - durColorByTimeCtl = GUI:CreateCheckbox(parent, L["Color by Time Remaining"], proxy, "durationColorByTime") - g:AddWidget(durColorByTimeCtl, 28) - AddDurationColorsLink(g, parent) - g:AddWidget(GUI:CreateColorPicker(parent, L["Duration Text Color"], proxy, "durationColor", true, RPL, RPL, true), 28) - local hideAboveSlider, hideAboveCheck - -- ASSIGNS the forward-declared local from the Duration Format dropdown - -- above (a `local function` here would shadow it and strand the - -- dropdown's callback on nil). Also greys the pair while a - -- percent-family format is picked — Hide Above's seconds threshold - -- can't band a percent-sampled formatter (see GetDurationFormatFields). - UpdateHideAboveState = function() - if not hideAboveSlider then return end - local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) - if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end - if not pctFmt and proxy.durationHideAboveEnabled then - hideAboveSlider:SetAlpha(1) - hideAboveSlider:EnableMouse(true) - else - hideAboveSlider:SetAlpha(0.4) - hideAboveSlider:EnableMouse(false) - end - end - hideAboveCheck = GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", UpdateHideAboveState) - g:AddWidget(hideAboveCheck, 28) - hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold") - g:AddWidget(hideAboveSlider, 54) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent"), 28) - UpdateHideAboveState() - end) - -- Expiration: the frame-anchored Expiry Alert ELEMENT (own section — - -- distinct from the sound "Expire Alert" group on the sound card). - AddGroup(L["Expiration"], function(g) - AddExpiryAlertControls(g, parent, proxy) - end) - -- Stack Count - AddGroup(L["Stack Count"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], proxy, "showStacks"), 28) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Stack Font"], proxy, "stackFont"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Stack Scale"], 0.5, 2.0, 0.1, proxy, "stackScale"), 54) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Stack Outline"], proxy, "stackOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "stackOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Stack Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "stackAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "stackX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "stackY"), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) - end) - -- Duration Bar (native SetDurationBar strip — shared with the icon card) - AddDurationBarGroup() - - elseif typeKey == "bar" then - -- Position - AddGroup(L["Position"], function(g) - if layoutGroup then - local groupNote = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - groupNote:SetTextColor(0.91, 0.66, 0.25, 0.8) - groupNote:SetText(format(L["Position managed by: %s"], layoutGroup.name or L["Layout Group"])) - g:AddWidget(groupNote, 18) - else - g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "anchor", function() DF:AuraDesigner_RefreshPage() end), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "offsetX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "offsetY"), 54) - end - end) - -- Size & Orientation - AddGroup(L["Size & Orientation"], function(g) - g:AddWidget(GUI:CreateDropdown(parent, L["Orientation"], OPTS.BAR_ORIENT_OPTIONS, proxy, "orientation", function() - local w = proxy.width - local h = proxy.height - proxy.width = h - proxy.height = w - local mw = proxy.matchFrameWidth - local mh = proxy.matchFrameHeight - proxy.matchFrameWidth = mh - proxy.matchFrameHeight = mw - DF:AuraDesigner_RefreshPage() - end), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Width"], 0, 200, 1, proxy, "width"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Height"], 1, 30, 1, proxy, "height"), 54) - g:AddWidget(GUI:CreateCheckbox(parent, L["Match Frame Width"], proxy, "matchFrameWidth"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Match Frame Height"], proxy, "matchFrameHeight"), 28) - end) - -- Texture & Colors (group captured to scroll to; the Color Mode widget to flash) - local colorModeDrop - local texColorsGroup = AddGroup(L["Texture & Colors"], function(g) - -- Colour Mode: Static uses Bar Texture + Fill Color; a curve (DF / Classic) swaps in a - -- green->red ramp the drain reveals — so the bar reddens as the aura expires — and - -- forces a white tint, so those two grey out (curveGated) while a curve is selected. - local curveGated = {} - local function UpdateColorModeGrey() - local curve = DF:IsDurationBarCurveMode(proxy.barColorMode) - for w in pairs(curveGated) do if w.SetEnabled then w:SetEnabled(not curve) end end - end - colorModeDrop = g:AddWidget(GUI:CreateDropdown(parent, L["Color Mode"], - DF:GetDurationBarColorModes(), proxy, "barColorMode", UpdateColorModeGrey), 54) - local texW = g:AddWidget(GUI:CreateTextureDropdown(parent, L["Bar Texture"], proxy, "texture"), 54) - local colW = g:AddWidget(GUI:CreateColorPicker(parent, L["Fill Color"], proxy, "fillColor", true, RPL, RPL, true), 28) - curveGated[texW] = true; curveGated[colW] = true - g:AddWidget(GUI:CreateColorPicker(parent, L["Background Color"], proxy, "bgColor", true, RPL, RPL, true), 28) - g:AddWidget(GUI:CreateSlider(parent, L["Alpha"], 0, 1, 0.05, proxy, "alpha"), 54) - g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Frame Level"], 0, 100, 1, proxy, "frameLevel")), 54) - UpdateColorModeGrey() -- initial grey for the curve-gated Texture / Fill Color - end) - -- Border (Stage 5.3 — unified controls via CreateBorderControls). - -- Full toolkit (Style / Texture / Colour / Gradient / Shadow / Blend / - -- Inset / Alpha + Animation). No offset (the bar has its own X/Y) and - -- no class/role (it's an aura bar, not unit identity). The bar's - -- expiring tints the FILL via its colour curve, so the icon/square - -- expiring-border overrides are intentionally not added here. - AddGroup(L["Border"], function(g) - GUI:CreateBorderControls(g, proxy, "", { - parent = parent, - include = { - inset = true, blendMode = true, gradient = true, - shadow = true, alpha = true, - }, - fullUpdate = RPL, - lightUpdate = RPL, - lightColors = RPL, - refreshStates = function() - g:LayoutChildren() - if parent.dfAD_ReflowWidgets then - parent.dfAD_ReflowWidgets() - end - end, - sizeMin = 1, sizeMax = 5, sizeStep = 1, - }) - end) - -- Duration Text - AddGroup(L["Duration Text"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], proxy, "showDuration"), 28) - -- The BAR is the wide surface, so the whole format family fits here: - -- the icon-sized three plus FULL ("14 Seconds") and the combined - -- Seconds + Percent ("12s (45%)" — 68914 multi-component text, #5). - local UpdateHideAboveState - g:AddWidget(GUI:CreateDropdown(parent, L["Duration Format"], { - NUMBER = L["Number"], SHORT = L["Seconds"], FULL = L["Full"], - PERCENT = L["Percent"], SECONDS_PERCENT = L["Seconds + Percent"], - _order = { "NUMBER", "SHORT", "FULL", "PERCENT", "SECONDS_PERCENT" }, - }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Duration Font"], proxy, "durationFont"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Duration Scale"], 0.5, 2.0, 0.1, proxy, "durationScale"), 54) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], proxy, "durationOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], proxy, "durationOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Duration Anchor"], OPTS.ANCHOR_OPTIONS, proxy, "durationAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, proxy, "durationX"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "durationY"), 54) - durColorByTimeCtl = GUI:CreateCheckbox(parent, L["Color by Time Remaining"], proxy, "durationColorByTime") - g:AddWidget(durColorByTimeCtl, 28) - AddDurationColorsLink(g, parent) - local hideAboveSlider, hideAboveCheck - -- ASSIGNS the forward-declared local from the Duration Format dropdown - -- above (a `local function` here would shadow it and strand the - -- dropdown's callback on nil). Also greys the pair while a - -- percent-family format is picked — Hide Above's seconds threshold - -- can't band a percent-sampled formatter (see GetDurationFormatFields). - UpdateHideAboveState = function() - if not hideAboveSlider then return end - local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) - if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end - if not pctFmt and proxy.durationHideAboveEnabled then - hideAboveSlider:SetAlpha(1) - hideAboveSlider:EnableMouse(true) - else - hideAboveSlider:SetAlpha(0.4) - hideAboveSlider:EnableMouse(false) - end - end - hideAboveCheck = GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", UpdateHideAboveState) - g:AddWidget(hideAboveCheck, 28) - hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold") - g:AddWidget(hideAboveSlider, 54) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent"), 28) - UpdateHideAboveState() - end) - -- Expiration: a bar carries its own colour-by-remaining-time (the Duration Bar Color Mode - -- reddens the fill as it drains, secret-safe and full-size), so the |T Border/Tint aren't - -- offered here — only a Text/Glyph alert. A note whose click jump-scrolls the card up to - -- the Color Mode. (A full InfoBanner link-banner can't live in this AD card — html needs - -- a post-SetWidth reflow the card's static-height path disables — so the whole note is the - -- click target, with "Color Mode" tinted to signal it, like the cross-S.page links.) - AddGroup(L["Expiration"], function(g) - -- Note with a jump-link: only "Color Mode" is clickable — clicking scrolls the card - -- up to Texture & Colors and flashes it. GUI:CreateLink = fixed-layout inline links - -- (safe in the reflowing card); GUI:LinkToSetting = scroll + "show me" flash. The |c - -- placeholder only satisfies the markup parser; CreateLink themes the link itself. - local function scrollToTexColors() - if not (S.tabScrollFrame and texColorsGroup and texColorsGroup.GetTop) then return end - local sfTop, gTop = S.tabScrollFrame:GetTop(), texColorsGroup:GetTop() - if not (sfTop and gTop) then return end - local target = S.tabScrollFrame:GetVerticalScroll() + (sfTop - gTop) - 8 - local maxS = S.tabScrollFrame:GetVerticalScrollRange() or 0 - S.tabScrollFrame:SetVerticalScroll(math.max(0, math.min(maxS, target))) - end - local link = format("|cffffffff|HdfADScroll:texcolors|h%s|h|r", L["Color Mode"]) - -- Fixed-layout note: hand it the group's inner width so its wrapped (2-line) height is - -- known before AddWidget — else it falls back to a fixed slot the text overflows. - local innerW = math.max(40, (g:GetWidth() or 260) - 2 * (g.padding or 10)) - local note = GUI:CreateLink(parent, format(L["For expiry colour, set the %s in Texture & Colors."], link), { - width = innerW, - onLinkClick = function() - -- Scroll the section into view, but FLASH the specific Color Mode widget - -- (LinkToSetting flashes target.widget — pass the group instead to flash the - -- whole section). - GUI:LinkToSetting({ widget = colorModeDrop or texColorsGroup, scrollTo = scrollToTexColors, - flash = { border = true } }) -- a single control: fill + outline - end, - }) - g:AddWidget(note, note.layoutHeight or 34) - AddExpiryAlertControls(g, parent, proxy, { border = false, tint = false, match = false }) - end) - - elseif typeKey == "border" then - -- Appearance — full DF.Border toolkit (Stage 5.4). The legacy 5 - -- styles are now combinations: Solid = Style SOLID; Glow = Style - -- TEXTURE + DF Glow; Dashed/Animated = Border Thickness 0 + DF Dash - -- (speed 0 / >0); Corners = Border Thickness 0 + Corners Only. - -- include offset too — this border covers the whole frame, so nudging - -- it can be useful. No class/role (it's an aura indicator). - AddGroup(L["Appearance"], function(g) - GUI:CreateBorderControls(g, proxy, "", { - parent = parent, - include = { - inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true, - }, - fullUpdate = RPL, - lightUpdate = RPL, - lightColors = RPL, - refreshStates = function() - g:LayoutChildren() - if parent.dfAD_ReflowWidgets then parent.dfAD_ReflowWidgets() end - end, - sizeMin = 0, sizeMax = 8, sizeStep = 1, - }) - -- Draw order: lift this border above the frame's own class/role - -- border so it fully covers it (on by default). Off tucks it back - -- underneath the frame border (the pre-5.4 stacking). - -- WIRED 2026-07-25 (was frosted as "engine-managed, not wired yet"). The frame's own - -- class/role border is a DF.Border child at frame+10, and the AD border container was - -- pinned at exactly +10 too -- same level, so which one won came down to creation - -- order. buildBorderConfig now resolves this flag to +11 (above) or +9 (below), and - -- the flag rides the border structSig so toggling it rebuilds at the new offset. - g:AddWidget(GUI:CreateCheckbox(parent, L["Draw above frame border"], proxy, "drawAboveFrameBorder", RPL), 28) - swmCheck = GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - g:AddWidget(swmCheck, 28) - end) - -- Expiring — full parity with icon/square (Stage 5.4): master enable + - -- State Overrides (border thickness / colour / alpha / animation swap) - -- + the existing Pulsate. The Expiring Animation lets the border swap - -- effect below threshold (e.g. solid → marching DF Dash). - -- Bar: single Expiring Colour, thicker max (8), a duration-priority row, - -- and no Icon-Effects (bars don't pulse/bounce the whole icon). - -- The border type draws no fill, so the expiring "tint" overlay has nothing - -- to tint and ApplyBorderToOverlay never calls SetupExpiringTint — hide the - -- otherwise-dead "Show Expiring Tint" / "Tint Color" controls for this type. - - elseif typeKey == "healthbar" then - -- Appearance - AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateDropdown(parent, L["Mode"], OPTS.HEALTHBAR_MODE_OPTIONS, proxy, "mode", function() - -- Rebuild so the Blend % slider's hideOn re-evaluates and the - -- group's height recomputes for the new visible-widget set. - DF:AuraDesigner_RefreshPage() - end), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) - local blendSlider = GUI:CreateSlider(parent, L["Blend %"], 0, 1, 0.05, proxy, "blend") - -- hideOn is re-evaluated on every LayoutChildren pass, so the slider - -- stays correctly hidden in Replace mode across GUI reopens. A manual - -- :Hide() would be clobbered by LayoutChildren's unconditional :Show(). - blendSlider.hideOn = function() return (proxy.mode or "Replace") == "Replace" end - g:AddWidget(blendSlider, 54) - -- Tint-mode only: tint the WHOLE bar (incl. missing health) instead of - -- just the current-health portion. Hidden in Replace mode (there the bar - -- IS the indicator, so this would hide health loss). Same hideOn as Blend. - wholeBarCheck = GUI:CreateCheckbox(parent, L["Tint Entire Bar"], proxy, "tintWholeBar", RPL) - wholeBarCheck.hideOn = function() return (proxy.mode or "Replace") == "Replace" end - g:AddWidget(wholeBarCheck, 28) - swmCheck = GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - g:AddWidget(swmCheck, 28) - end) - - elseif typeKey == "background" then - -- Appearance — mirrors Health Bar Color. A colour overlay over the frame - -- background (visible in the missing-health area). Replace = opaque cover; - -- Tint = blend × colour alpha so the normal background shows through. - AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateDropdown(parent, L["Mode"], OPTS.HEALTHBAR_MODE_OPTIONS, proxy, "mode", function() - DF:AuraDesigner_RefreshPage() - end), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) - local blendSlider = GUI:CreateSlider(parent, L["Blend %"], 0, 1, 0.05, proxy, "blend") - blendSlider.hideOn = function() return (proxy.mode or "Tint") == "Replace" end - g:AddWidget(blendSlider, 54) - swmCheck = GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - g:AddWidget(swmCheck, 28) - end) - - elseif typeKey == "nametext" then - -- Appearance - AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) - end) - - elseif typeKey == "healthtext" then - -- Appearance - AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) - end) - - - elseif typeKey == "sound" then - -- Enable checkbox - AddGroup(L["Sound Alert"], function(g) - -- Group-only warning banner - do - local topSpacer = CreateFrame("Frame", nil, parent) - topSpacer:SetHeight(4) - g:AddWidget(topSpacer, 4) - - local banner = GUI:CreateInfoBanner(parent, { - tone = "caution", - text = L["Sound alerts only work when you are in a group."], - }) - banner:SetWidth(contentWidth - 10) - g:AddWidget(banner, banner.layoutHeight) - - local spacer = CreateFrame("Frame", nil, parent) - spacer:SetHeight(6) - g:AddWidget(spacer, 6) - end - - -- Other Buffs: the on-apply sound path has no caster filter - -- (AddAuraAppliedSound), so an other-pool alert also fires for the - -- player's OWN cast — and for a spell that My Buffs also tracked, - -- both pools' sounds would fire. Surface it (B1 concern 2). - if IsOtherTab() then - local obBanner = GUI:CreateInfoBanner(parent, { - tone = "info", - text = L["Sound alerts play when anyone gains this buff, including your own casts."], - }) - obBanner:SetWidth(contentWidth - 10) - g:AddWidget(obBanner, obBanner.layoutHeight) - local obSpacer = CreateFrame("Frame", nil, parent) - obSpacer:SetHeight(6) - g:AddWidget(obSpacer, 6) - end - - local soundOn = proxy.enabled ~= false - g:AddWidget(GUI:CreateCheckbox(parent, L["Enable Sound Alert"], proxy, "enabled", function() - -- Stop sound immediately when disabled - if not proxy.enabled and DF.AuraDesigner.SoundEngine then - DF.AuraDesigner.SoundEngine:StopAura(auraName) - end - DF:AuraDesigner_RefreshPage() - end), 28) - - -- The flat sound below is the APPLIED sound (native Added trigger). This toggle - -- silences it independently of the Buff-Dropped / Stack-Gained events below. - if proxy.appliedEnabled == nil then proxy.appliedEnabled = true end - g:AddWidget(GUI:CreateCheckbox(parent, L["Play when the buff is applied"], proxy, "appliedEnabled", function() - DF:AuraDesigner_RefreshPage() - end), 28) - - -- Sound picker (searchable scrollable dropdown) - local soundDD = GUI:CreateSoundDropdown(parent, L["Sound"], proxy, "soundLSMKey", function() - -- Update soundFile path when LSM key changes - local path = DF:GetSoundPath(proxy.soundLSMKey) - if path then - proxy.soundFile = path - end - end) - g:AddWidget(soundDD, 54) - - -- Custom file path (overrides LSM selection) - local soundPathEB = GUI:CreateEditBox(parent, L["Custom Sound Path"], proxy, "soundFile", nil, 280) - g:AddWidget(soundPathEB, 44) - - -- Preview button - local previewBtn = GUI:CreateButton(parent, L["Preview Sound"], 120, 22, function() - local soundFile = DF:GetSoundPath(proxy.soundLSMKey) or proxy.soundFile - if not soundFile or soundFile == "" then - DF:Say(L["No sound file selected. Choose a sound from the dropdown or enter a custom path."]) - return - end - local volume = proxy.volume or 0.8 - if DF.AuraDesigner.SoundEngine then - local willPlay = DF.AuraDesigner.SoundEngine:PlayWithVolume(soundFile, volume) - if not willPlay then - DF:Say(format(L["Sound file could not be played: %s"], tostring(soundFile))) - end - end - end) - g:AddWidget(previewBtn, 28) - - -- Volume slider - local volumeSlider = GUI:CreateSlider(parent, L["Volume"], 0, 1, 0.05, proxy, "volume") - g:AddWidget(volumeSlider, 54) - - -- Grey out sound sub-controls when sound alert is disabled - if not soundOn then - soundDD:SetAlpha(0.4) - soundDD:EnableMouse(false) - soundPathEB:SetAlpha(0.4) - soundPathEB:EnableMouse(false) - previewBtn:SetAlpha(0.4) - previewBtn:EnableMouse(false) - volumeSlider:SetAlpha(0.4) - volumeSlider:EnableMouse(false) - end - end) - - -- Buff-Dropped + Stack-Gained sounds (native Removed / ApplicationsIncreased - -- triggers, 12.1). Each has its OWN sound, independently enabled. Distinct from the - -- blocked Missing Trigger (fires-while-absent) and Expire Alert (near-expiry - -- threshold), which still need sealed presence / remaining-time reads. On a pre- - -- rename client the triggers are absent — the group shows a note and the sound no-ops. - local newSoundTriggers = (C_UnitAuras and C_UnitAuras.AddAuraSound - and Enum and Enum.UnitAuraSoundTrigger) and true or false - local function AddEventSoundGroup(header, subKey, enableLabel) - AddGroup(header, function(g) - proxy[subKey] = proxy[subKey] or {} - local ec = proxy[subKey] - g:AddWidget(GUI:CreateCheckbox(parent, enableLabel, ec, "enabled", function() - DF:AuraDesigner_RefreshPage() - end), 28) - local dd = GUI:CreateSoundDropdown(parent, L["Sound"], ec, "soundLSMKey", function() - local path = DF:GetSoundPath(ec.soundLSMKey) - if path then ec.soundFile = path end - end) - g:AddWidget(dd, 54) - local prev = GUI:CreateButton(parent, L["Preview Sound"], 120, 22, function() - local sf = DF:GetSoundPath(ec.soundLSMKey) or ec.soundFile - if not sf or sf == "" then - DF:Say(L["No sound file selected. Choose a sound from the dropdown or enter a custom path."]) - return - end - if DF.AuraDesigner.SoundEngine then - DF.AuraDesigner.SoundEngine:PlayWithVolume(sf, proxy.volume or 0.8) - end - end) - g:AddWidget(prev, 28) - if not newSoundTriggers then - g:AddWidget(GUI:CreateNote(parent, - L["Needs the current game build — these sound triggers aren't available on this client yet."], - { width = contentWidth - 20 }), 40) - end - if not (ec.enabled == true) then - dd:SetAlpha(0.4); dd:EnableMouse(false) - prev:SetAlpha(0.4); prev:EnableMouse(false) - end - end) - end - AddEventSoundGroup(L["Buff Dropped"], "dropped", L["Enable Buff-Dropped Sound"]) - AddEventSoundGroup(L["Stack Gained"], "stackGained", L["Enable Stack-Gained Sound"]) - - - end - - -- ============================================================ - -- 12.1 AURA-SYSTEM STATUS OVERLAYS (P4.7 GUI pass) - -- Frost the AD effect-settings the 12.1 Blizzard aura system can't drive. - -- Every block gates on DF:FactoryOwnsAD(d). Only - -- the settings GROUPS built above are targeted — the trigger tags (the - -- working "which aura" layer) live in `parent` above `startY` and are left - -- alone. "roadmap" = temporary (delete the single call site when the port - -- lands); "limitation" = permanent (secret-value casualty). - -- ============================================================ - - if typeKey == "icon" or typeKey == "square" then - -- P4.3/P4.5 SHIPPED: icon/square render on the container engine (native icon / solid - -- fill + cooldown + stacks + border + position), AND Show When Missing now renders via - -- the read-free missing-mode container (static spell icon / colour square while absent). - -- The whole-type roadmap and the Show-When-Missing roadmap overlays are gone. Two - -- surgical blocks remain: - -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, - -- unreadable on the container path, so it is gone rather than frosted. The - -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). - -- * Min Stacks to Show — REMOVED 2026-07-25 (was a permanent limitation, then a - -- confirmed no-op). Stack counts render on the native no-formatter path (a - -- formatter would receive the SECRET application count and trap — see - -- Features/Auras.lua's stacks-formatter warning), so a custom minimum other than - -- the native "shown at >1" is not expressible. The control, its key and its - -- defaults are gone rather than frosted. - -- * Duration "Colour by Time" — P4.4 SHIPPED: the duration text now colours by - -- time via the native bucket formatter (C-side |c escapes), so the roadmap - -- overlay is gone and the control is fully editable under the factory. - elseif typeKey == "bar" then - -- P4.4 SHIPPED: the bar renders on the container engine (native SetDurationBar fill - -- + texture/colour/orientation + border + duration text). The whole-type roadmap - -- overlay is gone. One surgical block remains: - -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, - -- unreadable on the container path, so it is gone rather than frosted. The - -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). - elseif typeKey == "sound" then - -- P4.5 SHIPPED: the sound indicator plays natively via C_UnitAuras.AddAuraSound. - -- The Sound Alert group (picker / volume / preview) and the per-event groups - -- (Applied / Buff Dropped / Stack Gained) are all live and unblocked. - -- REMOVED 2026-07-25 -- Missing Trigger and Expire Alert. "Alert WHILE the buff is - -- absent" is presence-driven and no native hook fires during absence; "alert as the - -- buff FADES" is remaining-time-driven. Both were permanently frosted, and Expire - -- Alert's intent is now served natively by Buff Dropped (the Removed trigger), so - -- the groups and their ten inert keys are gone rather than left as dead controls. - elseif typeKey == "nametext" or typeKey == "healthtext" then - -- RECOVERED (colour-by-cover): the base Color works — the Text Designer keeps a - -- glyph-identical coloured cover in sync with the real element (Render mirrors) - -- and the aura slot's secret visibility shows it on presence. Two surgical blocks: - -- * Show When Missing is not built for text covers at all - the checkbox is - -- simply never created for them. A cover is SetAllPoints onto the real - -- FontString, so its screen rect belongs to the text it mirrors; missing mode - -- hides by MOVING a badge, so parking/pushing could never change what shows. - -- (Same control SHIPPED for border/healthbar/background, whose art really does - -- live inside the window.) Candidate path if it is ever wanted: - -- SetAlphaFromBoolean(presence, 0, 255) on the mirror - on SimpleRegion as well - -- as SimpleFrame, AllowedWhenTainted, and taking both alphas makes inversion - -- free. Open question is sourcing `presence` as a SECRET BOOLEAN we can pass - -- through without reading: an unrun in-game probe. - -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, - -- unreadable on the container path, so it is gone rather than frosted. The - -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). - elseif typeKey == "healthbar" or typeKey == "background" or typeKey == "border" then - -- Base effect settings (colour / mode / style) work on the container - -- engine. Two surgical blocks on top: - -- * Expiring -- REMOVED 2026-07-25. The whole group was remaining-time-driven, - -- unreadable on the container path, so it is gone rather than frosted. The - -- 12.1-safe replacement is the DF.Expiration engine (the Expiry Alert group). - -- * Show When Missing — P4.5 SHIPPED: the effect now renders via the read-free - -- missing-mode container (tint / ring shown while the buff is absent), so the - -- roadmap overlay is gone and the checkbox is fully editable under the factory. - -- * Tint Entire Bar (healthbar only) — NO LONGER blocked. The filled health-mirror - -- bar makes the current-health-fill variant (tintWholeBar=false) expressible - -- read-free, so both settings work under the factory. - -- * Gradient border style -- UNFROSTED 2026-07-25 to test the claim behind it. - -- The frost said gradient "needs a resolved rect to compute its direction+ - -- extent" and so degrades to solid on a secret-anchored slot. Re-reading - -- Border.lua that looks wrong on two counts: the gradient path measures - -- NOTHING (it is SetColorTexture + SetGradient on SetPoint-anchored edges -- - -- no GetWidth/GetHeight anywhere in Apply), and Apply's gradient branch is - -- gated on `style == "GRADIENT" and gradient and CreateColor` with no - -- _solidOnly check, so the slot's solidOnly flag never blocked the paint. - -- solidOnly is about secret COLOURS (CreateColor taints on them), which the - -- gradient pickers are not -- they are static config. secretRect is the flag - -- that handles rects, and only TEXTURE style needs it. If gradient still - -- renders solid in game, the cause is something not yet found and the block - -- should come back with the real reason recorded. - end - - totalHeight = totalHeight + 8 -- bottom padding - parent:SetHeight(totalHeight) - return widgets, totalHeight -end -P.BuildTypeContent = BuildTypeContent - --- ============================================================ --- GLOBAL VIEW (used by Global tab) --- ============================================================ - --- Hardcoded fallbacks for global defaults (used when profile is missing new keys) -local GLOBAL_DEFAULTS_FALLBACK = { - iconSize = 24, iconScale = 1.0, - showDuration = true, showStacks = true, - durationFont = "DF Roboto SemiBold", durationScale = 1.2, - durationOutline = "SHADOW;OUTLINE", durationAnchor = "CENTER", - durationX = 0, durationY = 0, durationColorByTime = true, - durationColor = {r = 1, g = 1, b = 1, a = 1}, - durationHideAboveEnabled = false, durationHideAboveThreshold = 10, - stackFont = "DF Roboto SemiBold", stackScale = 1.0, - stackOutline = "SHADOW;OUTLINE", stackAnchor = "BOTTOMRIGHT", - stackX = 2, stackY = -2, - stackColor = {r = 1, g = 1, b = 1, a = 1}, - iconBorderEnabled = true, iconBorderThickness = 1, - hideSwipe = false, hideIcon = false, -} - -local function BuildGlobalView(parent) - local adDB = GetAuraDesignerDB() - local rawDefaults = adDB.defaults - -- Proxy so every write triggers a full preview rebuild - -- (global defaults affect ALL indicators, need full teardown/rebuild) - -- Falls back to GLOBAL_DEFAULTS_FALLBACK for keys missing from existing profiles. - -- __dfDefaults exposes the fallback table to GUI:CreateColorPicker's Default button. - local defaults = setmetatable({ _skipOverrideIndicators = true, __dfDefaults = GLOBAL_DEFAULTS_FALLBACK }, { - __index = function(_, k) - local v = rawDefaults[k] - if v ~= nil then return v end - return GLOBAL_DEFAULTS_FALLBACK[k] - end, - __newindex = function(_, k, v) - rawDefaults[k] = v - RefreshPlacedIndicators() - RefreshPreviewEffects() - RefreshLiveFramesThrottled() - end, - }) - - local parentW = parent:GetWidth() - if parentW < 50 then parentW = 280 end - local contentWidth = parentW - 16 -- 8px padding each side - local totalHeight = 8 - local widgets = {} - local function RPL() if S.RefreshPreviewLightweight then S.RefreshPreviewLightweight() end end - - local function AddWidget(widget, height) - widget:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -totalHeight) - if widget.SetWidth then widget:SetWidth(contentWidth - 10) end - tinsert(widgets, widget) - totalHeight = totalHeight + (height or 30) - end - - local function AddGroup(header, buildFn) - local group = GUI:CreateSettingsGroup(parent, contentWidth - 10) - group.padding = 10 -- match the main Options groups' inner padding (airier scale) - group:AddWidget(GUI:CreateHeader(parent, header), GUI.RowHeight.sectionHeader) - buildFn(group) - local h = group:LayoutChildren() - AddWidget(group, h) - end - - -- ── GENERAL ── - AddGroup(L["General"], function(g) - g:AddWidget(GUI:CreateSlider(parent, L["Default Icon Size"], 8, 64, 1, defaults, "iconSize"), 50) - g:AddWidget(GUI:CreateSlider(parent, L["Default Scale"], 0.5, 3.0, 0.05, defaults, "iconScale"), 50) - -- Both LIVE on the container path: Factory.ResolveDefaults bundles them into the - -- per-pass `defs` table, resolveLevel/resolveStrata walk instance -> global default - -- (the same chain GLOBAL_DEFAULT_MAP gives the editor proxy, so the two agree), and - -- AuraContainer's _applyZOrder sets level and strata from the resolved config. - -- Both ship as no-ops -- level 0, strata INHERIT -- so an untouched profile renders - -- exactly where it did before they were wired. - g:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(parent, L["Default Frame Level"], 0, 100, 1, defaults, "indicatorFrameLevel")), 50) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration"], defaults, "showDuration"), 24) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], defaults, "showStacks"), 24) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Cooldown Swipe"], defaults, "hideSwipe"), 24) - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Icon (Text Only)"], defaults, "hideIcon"), 24) - end) - - -- ── SOUND ALERTS ── - -- Set-once settings, relocated here from the enable banner (§11.6 redesign). - -- Storage keys unchanged: soundEnabled (nil/true = on, false = muted) and - -- soundChannel (Master default: alerts should stay audible when the player - -- mutes Sound Effects/Music to cut combat noise). - AddGroup(L["Sound Alerts"], function(g) - local SOUND_CHANNELS = { - Master = L["Master"], - SFX = L["Sound Effects"], - Music = L["Music"], - Ambience = L["Ambience"], - Dialog = L["Dialog"], - _order = { "Master", "SFX", "Music", "Ambience", "Dialog" }, - } - g:AddWidget(GUI:CreateCheckbox(parent, L["Enabled"], nil, nil, nil, - function() return GetAuraDesignerDB().soundEnabled ~= false end, -- customGet - function(v) -- customSet - local adDB = GetAuraDesignerDB() - adDB.soundEnabled = v and true or false - if not adDB.soundEnabled and DF.AuraDesigner.SoundEngine then - DF.AuraDesigner.SoundEngine:StopAll() - end - end), 24) - g:AddWidget(GUI:CreateDropdown(parent, L["Channel"], SOUND_CHANNELS, - nil, nil, nil, - function() return (GetAuraDesignerDB().soundChannel) or "Master" end, -- customGet - function(key) GetAuraDesignerDB().soundChannel = key end), 50) -- customSet - end) - - -- ── DURATION TEXT ── - AddGroup(L["Duration Text"], function(g) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], defaults, "durationFont"), 50) - g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 2.0, 0.1, defaults, "durationScale"), 50) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], defaults, "durationOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], defaults, "durationOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, defaults, "durationAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, defaults, "durationX"), 50) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, defaults, "durationY"), 50) - g:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], defaults, "durationColorByTime"), 24) - AddDurationColorsLink(g, parent) - g:AddWidget(GUI:CreateColorPicker(parent, L["Duration Text Color"], defaults, "durationColor", true, RPL, RPL, true), 32) - local hideAboveSlider - local function UpdateHideAboveState() - if not hideAboveSlider then return end - if defaults.durationHideAboveEnabled then - hideAboveSlider:SetAlpha(1) - hideAboveSlider:EnableMouse(true) - else - hideAboveSlider:SetAlpha(0.4) - hideAboveSlider:EnableMouse(false) - end - end - g:AddWidget(GUI:CreateCheckbox(parent, L["Hide Duration Above Threshold"], defaults, "durationHideAboveEnabled", UpdateHideAboveState), 24) - hideAboveSlider = GUI:CreateSlider(parent, L["Hide Above (seconds)"], 1, 60, 1, defaults, "durationHideAboveThreshold") - g:AddWidget(hideAboveSlider, 50) - UpdateHideAboveState() - end) - - -- ── STACK TEXT ── - AddGroup(L["Stack Text"], function(g) - g:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], defaults, "stackFont"), 50) - g:AddWidget(GUI:CreateSlider(parent, L["Scale"], 0.5, 2.0, 0.1, defaults, "stackScale"), 50) - g:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], defaults, "stackOutline"), 54) - g:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], defaults, "stackOutline"), 28) - g:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], OPTS.ANCHOR_OPTIONS, defaults, "stackAnchor"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Offset X"], -150, 150, 1, defaults, "stackX"), 50) - g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, defaults, "stackY"), 50) - g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], defaults, "stackColor", true, RPL, RPL, true), 32) - end) - - -- ── IMPORT FROM BUFFS TAB ── - AddGroup(L["Import from Buffs Tab"], function(g) - local descFrame = CreateFrame("Frame", nil, parent) - descFrame:SetHeight(36) - local descText = descFrame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - descText:SetPoint("TOPLEFT", 0, 0) - descText:SetPoint("RIGHT", descFrame, "RIGHT", 0, 0) - descText:SetJustifyH("LEFT") - descText:SetWordWrap(true) - descText:SetText(L["Import your existing Buffs tab settings as defaults for all auras. Compatible settings will be applied automatically."]) - descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - g:AddWidget(descFrame, 36) - - -- Compatibility list - local compatItems = { - {true, L["Icon size, scale & border"]}, - {true, L["Duration & stack display"]}, - {true, L["Font Settings"]}, - {false, L["Position & anchors"]}, - {false, L["Per-aura overrides"]}, - } - for _, item in ipairs(compatItems) do - local isCompat = item[1] - local row = CreateFrame("Frame", nil, parent) - row:SetHeight(16) - local lbl = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 8, 0) - if isCompat then - lbl:SetText("|TInterface\\AddOns\\DandersFrames\\Media\\Icons\\check:12:12|t " .. item[2]) - else - lbl:SetText("|TInterface\\AddOns\\DandersFrames\\Media\\Icons\\close:12:12|t " .. item[2]) - end - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - g:AddWidget(row, 16) - end - - -- Import button - local importBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - DF.GUI:StyleButton(importBtn, { height = 26, text = L["Import Buffs Tab Defaults"] }) - importBtn:SetScript("OnClick", function() - local mode = (GUI and GUI.SelectedMode) or "party" - local buffsDB = DF:GetDB(mode) - if buffsDB and defaults then - if buffsDB.buffSize then defaults.iconSize = buffsDB.buffSize end - if buffsDB.buffScale then defaults.iconScale = buffsDB.buffScale end - if buffsDB.buffShowDuration ~= nil then defaults.showDuration = buffsDB.buffShowDuration end - if buffsDB.buffShowStacks ~= nil then defaults.showStacks = buffsDB.buffShowStacks end - if buffsDB.buffBorder ~= nil then defaults.iconBorderEnabled = buffsDB.buffBorder end - if buffsDB.buffDurationFont then defaults.durationFont = buffsDB.buffDurationFont end - if buffsDB.buffDurationScale then defaults.durationScale = buffsDB.buffDurationScale end - if buffsDB.buffDurationOutline then defaults.durationOutline = buffsDB.buffDurationOutline end - if buffsDB.buffStackFont then defaults.stackFont = buffsDB.buffStackFont end - if buffsDB.buffStackScale then defaults.stackScale = buffsDB.buffStackScale end - if buffsDB.buffStackOutline then defaults.stackOutline = buffsDB.buffStackOutline end - DF:Debug("AD", "Imported Buffs tab defaults") - importBtn.Text:SetText(L["Imported!"]) - C_Timer.After(1.5, function() importBtn.Text:SetText(L["Import Buffs Tab Defaults"]) end) - DF:AuraDesigner_RefreshPage() - end - end) - g:AddWidget(importBtn, 32) - end) - - -- ── STANDARD BUFFS ── - -- Replaces the old coexistence banner's "Disable Buffs" shortcut: standard - -- buff visibility is Aura Filters' job now, so this just links there. - AddGroup(L["Standard Buffs"], function(g) - local descFrame = CreateFrame("Frame", nil, parent) - descFrame:SetHeight(24) - local descText = descFrame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - descText:SetPoint("TOPLEFT", 0, 0) - descText:SetPoint("RIGHT", descFrame, "RIGHT", 0, 0) - descText:SetJustifyH("LEFT") - descText:SetWordWrap(true) - descText:SetText(L["Standard buff visibility is managed on the Aura Filters S.page."]) - descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - g:AddWidget(descFrame, 24) - - local filtersBtn = GUI:CreateButton(parent, L["Aura Filters"], 140, 22, function() - if GUI.SelectTab and GUI.Pages and GUI.Pages["auras_filterdesigner"] then - GUI.SelectTab("auras_filterdesigner") - end - end) - if not (GUI.Pages and GUI.Pages["auras_filterdesigner"]) then - filtersBtn:Disable() - filtersBtn.Text:SetTextColor(0.4, 0.4, 0.4) - end - g:AddWidget(filtersBtn, 28) - end) - - -- ── ACTIONS ── - AddGroup(L["Actions"], function(g) - -- Copy Settings to Other Mode button - local currentMode = (GUI and GUI.SelectedMode) or "party" - local targetMode = (currentMode == "party") and "raid" or "party" - local targetLabel = (targetMode == "raid") and L["Raid"] or L["Party"] - - local copyBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - DF.GUI:StyleButton(copyBtn, { height = 26, text = format(L["Copy Settings to %s"], targetLabel) }) - copyBtn:SetScript("OnClick", function() - local srcMode = (GUI and GUI.SelectedMode) or "party" - local dstMode = (srcMode == "party") and "raid" or "party" - -- Copy at the preset level: the source mode's preset content is - -- copied INTO the dest mode's preset, in place, so the dest preset - -- object identity (and every consumer bound to it) is preserved. - -- BASE resolvers: this S.page edits the user's BASE presets — with a - -- runtime auto-layout active, the ACTIVE resolver would copy - -- from/into the layout's preset instead. - local source = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(srcMode)) - or (DF.GetModeAuraDesigner and DF:GetModeAuraDesigner(srcMode)) - or (DF:GetDB(srcMode) and DF:GetDB(srcMode).auraDesigner) - local dest = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(dstMode)) - or (DF.GetModeAuraDesigner and DF:GetModeAuraDesigner(dstMode)) - or (DF:GetDB(dstMode) and DF:GetDB(dstMode).auraDesigner) - if source and dest and source ~= dest then - local function DeepCopy(src) - if type(src) ~= "table" then return src end - local copy = {} - for k, v in pairs(src) do copy[k] = DeepCopy(v) end - return copy - end - -- Clear stale dest keys the source no longer has, then overwrite. - for k in pairs(dest) do dest[k] = nil end - for k, v in pairs(source) do dest[k] = DeepCopy(v) end - end - DF:Debug("AD", "Copied %s settings to %s", tostring(srcMode), tostring(dstMode)) - end) - g:AddWidget(copyBtn, 32) - - -- Reset All button - local resetBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - resetBtn:SetHeight(26) - -- Persistent-red destructive button via the shared styler, now gated by a - -- confirmation (was a one-click wipe). - DF.GUI:StyleButton(resetBtn, { height = 26, primary = true, accent = { r = 0.8, g = 0.25, b = 0.25 }, text = L["Reset All Aura Configs"] }) - resetBtn:SetScript("OnClick", function() - DF:ShowPopupAlert({ - title = L["Reset All Aura Configs"], - message = L["Reset ALL aura configurations to defaults?\n\nThis cannot be undone."], - buttons = { - { - label = L["Reset"], - onClick = function() - wipe(GetAuraDesignerDB().auras) - -- "Reset ALL" covers the Other Buffs pool too (B2) - if GetAuraDesignerDB().otherAuras then - wipe(GetAuraDesignerDB().otherAuras) - end - DF:AuraDesigner_RefreshPage() - RefreshLiveFramesThrottled() - DF:Debug("AD", "Reset all aura configurations") - end, - }, - { label = L["Cancel"] }, - }, - }) - end) - g:AddWidget(resetBtn, 32) - end) - - parent:SetHeight(totalHeight + 10) -end - --- BuildPerAuraView + RefreshRightPanel removed in v4 redesign --- Per-aura configuration is now done via flat effect cards in the Effects tab --- (their dummy stubs were unreferenced — reclaimed for the 200-locals ceiling) - --- ============================================================ --- ENABLE BANNER --- ============================================================ - -local function CreateEnableBanner(parent) - local banner = CreateFrame("Frame", nil, parent, "BackdropTemplate") - -- Two-row layout: row 1 (36px) has Enable toggle (left) + Sync/Copy buttons - -- (right); row 2 (32px) is the preset bar (anchored into the banner by the - -- S.page build; the spec dropdown moved onto the B2 main tab strip). Sound - -- Alerts live on the Global tab (set-once settings). - banner:SetHeight(68) - banner:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0) - banner:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0) - GUI:CreatePanelBackdrop(banner, {borderColor = {r = 0.30, g = 0.30, b = 0.30, a = 0.5}}) - - -- Subtle divider between the two rows - local rowDivider = banner:CreateTexture(nil, "BACKGROUND") - rowDivider:SetHeight(1) - rowDivider:SetPoint("TOPLEFT", banner, "TOPLEFT", 0, -36) - rowDivider:SetPoint("TOPRIGHT", banner, "TOPRIGHT", 0, -36) - rowDivider:SetColorTexture(0.25, 0.25, 0.25, 1) - - -- Themed checkbox (matches GUI:CreateCheckbox style) - -- Row 1 centre = 18px from top. Banner centre = 34px from top. - -- Offset from banner centre to row 1 centre = +16. - local cb = CreateFrame("CheckButton", nil, banner, "BackdropTemplate") - cb:SetPoint("LEFT", banner, "LEFT", 10, 16) - DF.GUI:StyleCheckButton(cb) - - local adDB = GetAuraDesignerDB() - cb:SetChecked(adDB and adDB.enabled) - - cb:SetScript("OnClick", function(self) - local checked = self:GetChecked() - if checked then - -- Show popup asking about buff coexistence - ShowBuffCoexistPopup(function(keepBuffs) - GetAuraDesignerDB().enabled = true - S.db.showBuffs = keepBuffs - DF:AuraDesigner_RefreshPage() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end, function() - -- Cancelled — revert checkbox - self:SetChecked(false) - end) - else - GetAuraDesignerDB().enabled = false - DF:AuraDesigner_RefreshPage() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - -- Sync AD indicators to the now-disabled state — clears the leftover - -- indicators instead of leaving them frozen on screen until /reload. - if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end - end) - - local cbLabel = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") - cbLabel:SetPoint("LEFT", cb, "RIGHT", 8, 0) - cbLabel:SetText(L["Enable Aura Designer"]) - cbLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - banner.checkbox = cb - return banner -end -P.CreateEnableBanner = CreateEnableBanner - --- ============================================================ --- SPEC DROPDOWN (B2: relocated from the enable banner onto the --- main tab strip's right end — it only applies to My Buffs; the --- Other Buffs tab greys it with a "shared across specs" caption) --- ============================================================ - -local function CreateSpecDropdown(parent) - -- Spec selector. Ported to the shared GUI:CreateDropdown (inline mode, so the - -- container is just the opener button — the "Spec:" label beside it is - -- hand-placed by the S.page build). optionsFunc rebuilds the list each open so - -- the "Auto (Spec Name)" text always reflects the live detected spec. - -- The shared dropdown supports per-option colour (the `color` field), so the - -- class-coloured menu entries are preserved. (The OPENER text stays standard - -- colour — the shared opener isn't per-value colourable.) - local SPEC_ORDER = { - "auto", - -- Grouped by class (class order), specs in spec-index order - "ArmsWarrior", "FuryWarrior", "ProtectionWarrior", - "HolyPaladin", "ProtectionPaladin", "RetributionPaladin", - "BeastMasteryHunter", "MarksmanshipHunter", "SurvivalHunter", - "AssassinationRogue", "OutlawRogue", "SubtletyRogue", - "DisciplinePriest", "HolyPriest", "ShadowPriest", - "BloodDeathKnight", "FrostDeathKnight", "UnholyDeathKnight", - "ElementalShaman", "EnhancementShaman", "RestorationShaman", - "ArcaneMage", "FireMage", "FrostMage", - "AfflictionWarlock", "DemonologyWarlock", "DestructionWarlock", - "BrewmasterMonk", "MistweaverMonk", "WindwalkerMonk", - "BalanceDruid", "FeralDruid", "GuardianDruid", "RestorationDruid", - "HavocDemonHunter", "VengeanceDemonHunter", "DevourerDemonHunter", - "DevastationEvoker", "PreservationEvoker", "AugmentationEvoker", - } - local function SpecOptionText(specKey) - if specKey == "auto" then - local autoSpec = Adapter:GetPlayerSpec() - if autoSpec then - return format(L["Auto (%s)"], Adapter:GetSpecDisplayName(autoSpec)) - end - return L["Auto (detect spec)"] - end - return Adapter:GetSpecDisplayName(specKey) - end - local function SpecOptionColor(specKey) - local resolved = specKey - if specKey == "auto" then resolved = Adapter:GetPlayerSpec() end - local info = resolved and DF.AuraDesigner.SpecInfo and DF.AuraDesigner.SpecInfo[resolved] - local cc = info and info.class and RAID_CLASS_COLORS[info.class] - if cc then return { r = cc.r, g = cc.g, b = cc.b } end - return nil - end - -- All-spec menu: "Auto ()" pinned first, then every spec grouped - -- under a class-coloured header row, with the shared dropdown's inline - -- search (opts.searchable) so 41 entries stay navigable. - local function BuildSpecOptions() - local order = {} - local options = { _order = order } - tinsert(order, "auto") - options.auto = { - value = "auto", - text = SpecOptionText("auto"), - color = SpecOptionColor("auto"), - } - local lastClass - for _, specKey in ipairs(SPEC_ORDER) do - if specKey ~= "auto" then - local info = DF.AuraDesigner.SpecInfo and DF.AuraDesigner.SpecInfo[specKey] - local classToken = info and info.class - if classToken and classToken ~= lastClass then - lastClass = classToken - local hdrKey = "__hdr_" .. classToken - local cc = RAID_CLASS_COLORS[classToken] - tinsert(order, hdrKey) - options[hdrKey] = { - header = true, - text = (LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[classToken]) or classToken, - color = cc and { r = cc.r, g = cc.g, b = cc.b } or nil, - } - end - tinsert(order, specKey) - options[specKey] = { - value = specKey, - text = SpecOptionText(specKey), - color = SpecOptionColor(specKey), - } - end - end - return options - end - - local specDrop = GUI:CreateDropdown( - parent, "", BuildSpecOptions(), - nil, nil, nil, - function() return GetAuraDesignerDB().spec or "auto" end, -- customGet - function(key) -- customSet - GetAuraDesignerDB().spec = key - -- Clear expanded cards (auras change with spec), and close the - -- shared spell picker — its records/handlers captured the OLD - -- spec's state at open time (same staleness as a tab switch). - wipe(expandedCards) - CloseADPicker() - DF:AuraDesigner_RefreshPage() - end, - -- menuAlign RIGHT: the opener sits near the strip's right side and the - -- menu is wider than it, so surplus width grows leftward (menu TOPRIGHT - -- pinned to the opener's BOTTOMRIGHT) instead of spilling off the edge. - { inline = true, optionsFunc = BuildSpecOptions, searchable = true, menuAlign = "RIGHT" } - ) - - local function UpdateSpecText() - if specDrop.RebuildOptions then specDrop:RebuildOptions(BuildSpecOptions()) end - if specDrop.UpdateText then specDrop:UpdateText() end - end - - return specDrop, UpdateSpecText -end -P.CreateSpecDropdown = CreateSpecDropdown - --- ============================================================ --- FRAME PREVIEW --- Mock unit frame with health bar, power bar, name, health %, --- and 9 anchor point dots for indicator placement --- ============================================================ - -local function CreateFramePreview(parent, yOffset, rightPanelRef) - -- Read current frame settings for the preview - local mode = (GUI and GUI.SelectedMode) or "party" - local frameDB = DF:GetDB(mode) or DF.PartyDefaults - local FRAME_W = frameDB.frameWidth or 125 - local FRAME_H = frameDB.frameHeight or 64 - local POWER_H = frameDB.powerBarHeight or 4 - local showPower = frameDB.showPowerBar - - -- Preview scale from AD settings - local adDB = GetAuraDesignerDB() - local previewScale = adDB.previewScale or 1.0 - - -- Outer container with label - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - local rightInset = rightPanelRef and (rightPanelRef:GetWidth() + 6) or 0 - container:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, yOffset) - container:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -rightInset, yOffset) - container:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0) - container:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -rightInset, 0) - -- Dark bg + DIM border (matches Text Designer; no solid white outline). - ApplyBackdrop(container, {r = 0.10, g = 0.10, b = 0.10, a = 1}, {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}) - -- Apply the subtle spec class-color hint immediately. CreateFramePreview runs - -- on every S.page build — including a party/raid rebuild (S.page:Refresh always - -- rebuilds) — so without this the new preview falls back to the dim default - -- border until the next AuraDesigner_RefreshPage (spec change / tab revisit). - local cbSpec = ResolveSpec() - local cbInfo = cbSpec and DF.AuraDesigner.SpecInfo and DF.AuraDesigner.SpecInfo[cbSpec] - local cbColor = cbInfo and cbInfo.class and RAID_CLASS_COLORS[cbInfo.class] - if cbColor then - container:SetBackdropBorderColor(cbColor.r, cbColor.g, cbColor.b, 0.5) - end - - -- "Frame Preview" label - local previewLabel = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - previewLabel:SetPoint("TOPLEFT", 8, -4) - previewLabel:SetText(L["FRAME PREVIEW"]) - previewLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Mock unit frame (centered in container) - local mockFrame = CreateFrame("Frame", nil, container, "BackdropTemplate") - mockFrame:SetSize(FRAME_W, FRAME_H) - mockFrame:SetPoint("CENTER", container, "CENTER", 0, -4) - mockFrame:SetScale(previewScale) - ApplyBackdrop(mockFrame, {r = 0.07, g = 0.07, b = 0.07, a = 1}, {r = 0.27, g = 0.27, b = 0.27, a = 1}) - container.mockFrame = mockFrame - - -- Resolve health texture - local healthTexPath = frameDB.healthTexture or "Interface\\Buttons\\WHITE8x8" - - -- Health bar background - local healthBg = mockFrame:CreateTexture(nil, "BACKGROUND") - healthBg:SetPoint("TOPLEFT", 1, -1) - if showPower then - healthBg:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, POWER_H + 1) - else - healthBg:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, 1) - end - healthBg:SetColorTexture(0, 0, 0, 0.4) - -- Exposed so the preview can tint the background when an AD Background Color - -- effect is configured. - container.healthBg = healthBg - - -- Health bar fill (72% health) - local healthFill = mockFrame:CreateTexture(nil, "ARTWORK") - healthFill:SetPoint("TOPLEFT", 1, -1) - if showPower then - healthFill:SetPoint("BOTTOMLEFT", mockFrame, "BOTTOMLEFT", 1, POWER_H + 1) - else - healthFill:SetPoint("BOTTOMLEFT", mockFrame, "BOTTOMLEFT", 1, 1) - end - healthFill:SetWidth(FRAME_W * 0.72) - healthFill:SetTexture(healthTexPath) - healthFill:SetVertexColor(0.18, 0.80, 0.44, 0.85) - container.healthFill = healthFill - - -- Missing health region - local missingHealth = mockFrame:CreateTexture(nil, "ARTWORK") - missingHealth:SetPoint("TOPRIGHT", mockFrame, "TOPRIGHT", -1, -1) - if showPower then - missingHealth:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, POWER_H + 1) - else - missingHealth:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, 1) - end - missingHealth:SetWidth(FRAME_W * 0.28) - missingHealth:SetColorTexture(0, 0, 0, 0.4) - -- Exposed so the preview can tint the missing-health region when the - -- health-bar indicator is in Tint mode with "Tint Entire Bar" enabled. - container.missingHealth = missingHealth - - -- Power bar (only if enabled in settings) - if showPower then - local powerBg = mockFrame:CreateTexture(nil, "ARTWORK") - powerBg:SetPoint("BOTTOMLEFT", 1, 1) - powerBg:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, 0) - powerBg:SetHeight(POWER_H) - powerBg:SetColorTexture(0.07, 0.07, 0.07, 1) - - local powerFill = mockFrame:CreateTexture(nil, "ARTWORK", nil, 1) - powerFill:SetPoint("BOTTOMLEFT", 1, 1) - powerFill:SetHeight(POWER_H) - powerFill:SetWidth(FRAME_W * 0.85) - powerFill:SetColorTexture(0.27, 0.53, 1, 0.9) - - -- Power bar top border - local powerBorder = mockFrame:CreateTexture(nil, "ARTWORK", nil, 2) - powerBorder:SetPoint("BOTTOMLEFT", mockFrame, "BOTTOMLEFT", 1, POWER_H) - powerBorder:SetPoint("BOTTOMRIGHT", mockFrame, "BOTTOMRIGHT", -1, POWER_H) - powerBorder:SetHeight(1) - powerBorder:SetColorTexture(0.2, 0.2, 0.2, 1) - end - - -- Resolve fonts from settings - local nameFontPath = DF:GetFontPath(frameDB.nameFont) or "Fonts\\FRIZQT__.TTF" - local nameFontSize = frameDB.nameFontSize or 11 - local healthFontPath = DF:GetFontPath(frameDB.healthFont) or "Fonts\\FRIZQT__.TTF" - local healthFontSize = frameDB.healthFontSize or 10 - - -- Name text (uses user's font + anchor settings) - local nameAnchor = frameDB.nameTextAnchor or "TOP" - local nameOffX = frameDB.nameTextX or 0 - local nameOffY = frameDB.nameTextY or -10 - - local nameText = mockFrame:CreateFontString(nil, "OVERLAY") - nameText:SetFont(nameFontPath, nameFontSize, "OUTLINE") - nameText:SetPoint(nameAnchor, mockFrame, nameAnchor, nameOffX, nameOffY) - nameText:SetText("Danders") - nameText:SetTextColor(0.18, 0.80, 0.44, 1) - container.nameText = nameText - - -- Health percentage (uses user's font + anchor settings) - local healthAnchor = frameDB.healthTextAnchor or "CENTER" - local healthOffX = frameDB.healthTextX or 0 - local healthOffY = frameDB.healthTextY or 4 - - if frameDB.showHealthText ~= false then - local hpText = mockFrame:CreateFontString(nil, "OVERLAY") - hpText:SetFont(healthFontPath, healthFontSize, "OUTLINE") - hpText:SetPoint(healthAnchor, mockFrame, healthAnchor, healthOffX, healthOffY) - hpText:SetText("72%") - hpText:SetTextColor(0.87, 0.87, 0.87, 1) - container.hpText = hpText - end - - -- Border overlay (used when border effect is active) — Stage 5.4: a - -- DF.Border widget covering the mock frame, mirroring the runtime. - container.borderOverlay = DF.Border:New(mockFrame, { frameLevelOffset = 5, layer = "OVERLAY" }) - - -- Click background — no-op in new UI (was used to deselect aura in old tile view) - local bgClick = CreateFrame("Button", nil, mockFrame) - bgClick:SetAllPoints() - bgClick:SetFrameLevel(mockFrame:GetFrameLevel() + 1) -- Below dots and indicators - bgClick:RegisterForClicks("LeftButtonUp") - - -- ======================================== - -- 9 ANCHOR POINT DOTS - -- ======================================== - wipe(anchorDots) - for anchorName, pos in pairs(ANCHOR_POSITIONS) do - local dotFrame = CreateFrame("Frame", nil, mockFrame) - dotFrame:SetSize(20, 20) - dotFrame:SetFrameLevel(mockFrame:GetFrameLevel() + 10) - - -- Position the dot zone - dotFrame:SetPoint(pos.ax, mockFrame, pos.ay, 0, 0) - - -- The visible dot - local dc = GetThemeColor() - local dot = dotFrame:CreateTexture(nil, "OVERLAY") - dot:SetSize(6, 6) - dot:SetPoint("CENTER", 0, 0) - dot:SetColorTexture(dc.r, dc.g, dc.b, 0.3) - dotFrame.dot = dot - - -- Hover zone (invisible button) -- also acts as drop target during drag - local hoverBtn = CreateFrame("Button", nil, dotFrame) - hoverBtn:SetAllPoints() - local capturedAnchorName = anchorName - hoverBtn:SetScript("OnEnter", function() - if dragState.isDragging then - -- Drag hover: enlarge and accent-color the dot - local tc = GetThemeColor() - dot:SetSize(14, 14) - dot:SetColorTexture(tc.r, tc.g, tc.b, 0.9) - dragState.dropAnchor = capturedAnchorName - -- Update hint to show target anchor - if S.dragHintText and dragState.auraInfo then - S.dragHintText:SetText(format(L["Place %s at %s"], dragState.auraInfo.display, capturedAnchorName)) - end - else - local tc = GetThemeColor() - dot:SetSize(10, 10) - dot:SetColorTexture(tc.r, tc.g, tc.b, 0.7) - end - end) - hoverBtn:SetScript("OnLeave", function() - if dragState.isDragging then - -- Revert to drag-active state (not default) - local tc = GetThemeColor() - dot:SetSize(10, 10) - dot:SetColorTexture(tc.r, tc.g, tc.b, 0.5) - dragState.dropAnchor = nil - -- Revert hint to generic drag message - if S.dragHintText and dragState.auraInfo then - S.dragHintText:SetText(format(L["Drop on an anchor point to place %s"], dragState.auraInfo.display)) - S.dragHintText:SetTextColor(tc.r, tc.g, tc.b, 0.9) - end - else - local tc = GetThemeColor() - dot:SetSize(6, 6) - dot:SetColorTexture(tc.r, tc.g, tc.b, 0.3) - end - end) - - dotFrame.anchorName = anchorName - dotFrame:Hide() -- Only visible during active drags - anchorDots[anchorName] = dotFrame - end - - -- Instructions with keyboard badge styling - local instrRows = { - { key = L["Click"], desc = L["an indicator on the frame to expand its settings"] }, - { key = L["Drag"], desc = L["a placed indicator to reposition it on the frame"] }, - { key = L["Right-click"], desc = L["a placed indicator to remove it from the frame"] }, - } - - local instrCount = #instrRows - for i, row in ipairs(instrRows) do - local rowBottomOffset = 10 + (instrCount - i) * 18 - - -- Key badge background - local badge = CreateFrame("Frame", nil, container, "BackdropTemplate") - badge:SetHeight(13) - badge:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 8, rowBottomOffset) - ApplyBackdrop(badge, C_ELEMENT, C_BORDER) - - local keyText = badge:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - keyText:SetPoint("CENTER", 0, 0) - keyText:SetText(row.key) - keyText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - local keyWidth = keyText:GetStringWidth() - badge:SetWidth(max(keyWidth + 10, 20)) - - -- Description text (word-wrapped within container bounds) - local descText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - descText:SetPoint("LEFT", badge, "RIGHT", 5, 0) - descText:SetPoint("RIGHT", container, "RIGHT", -8, 0) - descText:SetWordWrap(true) - descText:SetJustifyH("LEFT") - descText:SetText(row.desc) - descText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) - end - - -- ======================================== - -- PREVIEW SCALE SLIDER - -- ======================================== - local scaleSlider = GUI:CreateSlider(container, L["Preview Scale"], 0.75, 2.5, 0.05, adDB, "previewScale", - -- callback (on release) - function() - local s = adDB.previewScale or 1.0 - mockFrame:SetScale(s) - end, - -- lightweightUpdate (during drag) - function() - local s = adDB.previewScale or 1.0 - mockFrame:SetScale(s) - end - ) - scaleSlider:SetPoint("TOPLEFT", previewLabel, "BOTTOMLEFT", -4, -4) - scaleSlider:SetSize(220, 30) - - -- Drag-state hint text (shows contextual guidance during drag operations) - S.dragHintText = container:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(S.dragHintText, 9, "OUTLINE") - S.dragHintText:SetPoint("TOP", mockFrame, "BOTTOM", 0, -6) - S.dragHintText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) - S.dragHintText:SetText("") - - return container -end -P.CreateFramePreview = CreateFramePreview - --- ============================================================ --- TAB SYSTEM, SPELL PICKER & EFFECT CARDS (v4 redesign) --- Functions for the new tabbed right panel, spell picker overlay, --- and collapsible effect card rendering. --- ============================================================ - --- Forward declarations (mutually referencing functions) --- (S.SwitchTab declared on the state table) --- (S.BuildEffectsTab, S.BuildGlobalTab, S.BuildLayoutGroupsTab, S.BuildDebuffGroupsTab declared on the state table) --- (S.CreateEffectCard declared on the state table) - --- (S.spellPickerBlockedIDs declared on the state table) - -- cross-tab block; rebuilt per picker open) -local spellPickerBlockCache = {} -- auraName -> bool memo over S.spellPickerBlockedIDs - -- (wiped whenever the set is rebuilt) so blocked - -- checks don't re-resolve identity per row bind - --- Cross-tab used check for one picker candidate: any of its identity IDs --- (nil-spec identity on the Other tab — the naming contract's resolver — --- else the spec identity) already tracked by the opposite pool. -local function IsCandidateCrossBlocked(auraName, spec) - if not S.spellPickerBlockedIDs or not next(S.spellPickerBlockedIDs) then return false end - local cached = spellPickerBlockCache[auraName] - if cached ~= nil then return cached end - local blocked = false - local f = DF:BuildADIdentityFilters(IsOtherTab() and nil or spec, auraName) - local map = f and f.includeSpellIDs - if map then - for id in pairs(map) do - if S.spellPickerBlockedIDs[id] then blocked = true; break end - end - end - spellPickerBlockCache[auraName] = blocked - return blocked -end - --- Check if a specific aura has a frame-level effect of given type -local function HasFrameEffect(auraName, typeKey) - local auraCfg = CurrentAuraPool()[auraName] - return auraCfg and auraCfg[typeKey] ~= nil -end - --- Clear all child frames and regions from the tab content area -local function ClearTabContent() - if not S.tabContentFrame then return end - local children = { S.tabContentFrame:GetChildren() } - for _, child in ipairs(children) do - child:Hide() - child:ClearAllPoints() - end - local regions = { S.tabContentFrame:GetRegions() } - for _, region in ipairs(regions) do - region:Hide() - end -end - --- ── SHARED PICKER PLUMBING ── --- Restore hook for the shared picker: fires on ANY close (back/ESC/ --- programmatic/ancestor hide). Brings the tab surfaces back and rebuilds --- the active tab when an add landed while the picker stayed open. -local function ADPickerClosed() - S.spellPickerBlockedIDs = nil -- recomputed on next open (memo wiped with it) - if S.tabBar then S.tabBar:Show() end - if S.tabScrollFrame then S.tabScrollFrame:Show() end - if S.adPickerDirty then - S.adPickerDirty = false - S.SwitchTab(S.activeTab or "effects") - end -end - --- Open prelude shared by the three AD contexts: fresh cross-tab block set --- (the memo over it persists across row rebinds while the picker is up), --- hide the tab surfaces the overlay replaces, and open the shared picker --- over the right panel. -local function OpenADPicker(opts) - S.spellPickerBlockedIDs = CrossPoolTrackedIDs() - wipe(spellPickerBlockCache) - S.adPickerDirty = false - if S.tabBar then S.tabBar:Hide() end - if S.tabScrollFrame then S.tabScrollFrame:Hide() end - opts.parent = S.rightPanel - opts.onClose = ADPickerClosed - -- Empty record list on My Buffs = unsupported/undetected spec: keep - -- the old picker's guidance instead of a bare "No results found". - -- (Other Buffs records are the full SpellDB — never empty.) - if not IsOtherTab() then - opts.emptyText = L["No trackable spells found for this spec.\n\nYou can select a different spec using the dropdown above."] - end - S.adPickerHandle = DF.FilterRegistry:OpenSpellPicker(opts) -end - --- ── PICKER RECORDS ── --- Shared-picker record list for the ACTIVE tab. My Buffs adapts the spec's --- merged trackable list (curated Config entries + the SpellDB class pool + --- class="ALL"); Other Buffs adapts the full SpellDB pool. includeAdHoc adds --- configured "#" auras (group + trigger pickers — never in the --- trackable pool). Records carry the SpellDB-compatible shape the shared --- picker renders (id / class / cats plus display and icon overrides — the --- icon override keeps the static IconTextures talent-guard) plus --- `auraName`, the stable AD config key the row handlers write. -local function BuildADPickerRecords(includeAdHoc) - local isOther = IsOtherTab() - local spec = (not isOther) and ResolveSpec() or nil - local auras - if isOther then - auras = Adapter and Adapter.GetAllTrackableAuras and Adapter:GetAllTrackableAuras() - else - auras = spec and Adapter and Adapter:GetTrackableAuras(spec) - end - if not auras then return {} end - if includeAdHoc then - auras = WithConfiguredAdHocAuras(auras, spec) - end - local specInfo = spec and DF.AuraDesigner.SpecInfo[spec] - local lockClass = specInfo and specInfo.class - local R = DF.FilterRegistry - local tooltipOverrides = DF.AuraDesigner.TooltipSpellIDs - local specIDs = spec and DF.AuraDesigner.SpellIDs and DF.AuraDesigner.SpellIDs[spec] - local out = {} - for _, ai in ipairs(auras) do - -- Tooltip/canonical id: override table, else the spec whitelist, - -- else the pool entry's canonical id (same chain as the old cards) - local id = (tooltipOverrides and tooltipOverrides[ai.name]) - or (specIDs and specIDs[ai.name]) or ai.spellID - if type(id) == "table" then id = id[1] end -- rare multi-id entries - local rec = R and R.ByID and id and R.ByID[id] - out[#out + 1] = { - id = id or 0, - class = ai.class or lockClass or "ALL", - cats = rec and rec.cats or nil, - display = ai.display or ai.name, - icon = GetAuraIcon(spec, ai.name), - -- Letter/colour-swatch fallback for auras whose icon texture - -- doesn't resolve (the old card fallback) - iconColor = type(ai.color) == "table" and ai.color or nil, - auraName = ai.name, - } - end - return out -end - --- Cross-tab block caption for one picker record (B2): a spell tracked by --- the OPPOSITE pool renders dimmed, captioned with the tab it lives in, --- and every add path is blocked. -local function ADCrossBlockText(rec) - if IsCandidateCrossBlocked(rec.auraName, ResolveSpec()) then - return IsOtherTab() and L["In My Buffs"] or L["In Any Buff"] - end - return nil -end - --- ── SWITCH TAB ── -S.SwitchTab = function(tabKey) - -- Effects is frosted on the Debuffs tab (C2: category groups have no - -- placed indicators) — coerce to Layout Groups (belt-and-braces; the - -- sub-tab button is also frosted). - if tabKey == "effects" and IsDebuffTab() then - tabKey = "layout" - end - -- Preserve scroll position when refreshing the same tab - local prevTab = S.activeTab - local savedScroll = 0 - if tabKey == prevTab and S.tabScrollFrame then - savedScroll = S.tabScrollFrame:GetVerticalScroll() - end - - S.activeTab = tabKey - -- This switch rebuilds the tab anyway — skip the close hook's own - -- dirty rebuild so the tab isn't built twice. - S.adPickerDirty = false - CloseADPicker() - if GUI then GUI:CloseAllMenus() end -- an open dropdown (e.g. spec) must not outlive the tab - - for key, btn in pairs(tabButtons) do - btn:SetActive(key == tabKey) -- underline + accent/dim label (tab mode) - end - - ClearTabContent() - - if tabKey == "effects" then - S.BuildEffectsTab() - elseif tabKey == "layout" then - -- The Debuffs tab's Layout Groups list shows ONLY debuff category - -- groups; My Buffs / Other Buffs each build their OWN pool's - -- member+filter groups (S.BuildLayoutGroupsTab is pool-routed). - if IsDebuffTab() then - S.BuildDebuffGroupsTab() - else - S.BuildLayoutGroupsTab() - end - elseif tabKey == "global" then - S.BuildGlobalTab() - end - - if S.tabScrollFrame then - if tabKey == prevTab then - -- Clamp to new max scroll range (content may have changed height) - local maxScroll = S.tabScrollFrame:GetVerticalScrollRange() - S.tabScrollFrame:SetVerticalScroll(min(savedScroll, maxScroll)) - else - S.tabScrollFrame:SetVerticalScroll(0) - end - end -end - --- ── MAIN POOL TAB SWITCH (B2/C2: My Buffs / Debuffs / Other Buffs) ── - --- Grey/restore the sub-tabs: frosted (SetDisabled — stays mouse-enabled so --- the tooltip can explain why; OnClick early-outs on dfDisabled). Effects --- frosts on the Debuffs tab (category groups have no placed indicators). --- Layout Groups is live on BOTH buff tabs (the Other tab hosts the flat --- other-pool group store) — it never frosts anymore. -local function UpdateLayoutTabState() - local layoutBtn = tabButtons and tabButtons.layout - if layoutBtn and layoutBtn.SetDisabled then - layoutBtn:SetDisabled(false) - end - local effectsBtn = tabButtons and tabButtons.effects - if effectsBtn and effectsBtn.SetDisabled then - effectsBtn:SetDisabled(IsDebuffTab()) - end -end -P.UpdateLayoutTabState = UpdateLayoutTabState - --- Grey the spec dropdown + swap its opener text on the Other and Debuffs --- tabs (both pools are shared across specs); restore the live spec text on --- My Buffs. -local function UpdateSpecDropdownState() - if not S.specDropdown then return end - if IsOtherTab() or IsDebuffTab() then - if S.specDropdown.SetDisplayOverride then - S.specDropdown:SetDisplayOverride(L["— (shared across specs)"]) - end - S.specDropdown:SetEnabled(false) - else - S.specDropdown:SetEnabled(true) - if S.specDropdown.SetDisplayOverride then - S.specDropdown:SetDisplayOverride(nil) - end - if S.specDropdownUpdate then S.specDropdownUpdate() end - end -end -P.UpdateSpecDropdownState = UpdateSpecDropdownState - -local function SetMainTab(tabKey) - if S.activeBuffTab == tabKey then return end - S.activeBuffTab = tabKey - -- Editor keys are pool-prefixed (B1) so cards can't collide across tabs, - -- but mirror the spec dropdown's behavior: a pool switch collapses all - -- expanded cards (wipe, not per-tab preservation). - wipe(expandedCards) - -- The shared picker captures its pool/effect at open time — never let - -- it survive a pool switch. - CloseADPicker() - if GUI then GUI:CloseAllMenus() end -- an open dropdown (e.g. spec) must not outlive the tab - for key, btn in pairs(mainTabButtons) do - btn:SetActive(key == tabKey) - end - UpdateSpecDropdownState() - UpdateLayoutTabState() - -- Effects is frosted on the Debuffs tab, so land on Layout Groups (the - -- tab's primary surface). Layout Groups is live on both buff tabs — no - -- coercion needed when arriving there. - if S.activeBuffTab == "debuffs" and S.activeTab == "effects" then - S.activeTab = "layout" - end - -- One entry point swaps every surface: RefreshPage → S.SwitchTab(S.activeTab) - -- (list, chips, add menu) + RefreshPlacedIndicators/RefreshPreviewEffects - -- (preview, drag targets) — all pool-routed through CurrentAuraPool. - DF:AuraDesigner_RefreshPage() -end -P.SetMainTab = SetMainTab - --- ── ADD FROM PICKER (shared path) ── --- What accepting a spell in the picker DOES: create the placed indicator --- instance (or the frame-level type config, mode = "frame") and pre-expand --- its effect card. Used by both the row handler and add-by-ID so the two --- entry points can never drift apart. -local function AddPickedSpell(auraName, typeKey, mode) - -- Card keys embed the B1 pool prefix in the name segment - -- ("placed:other:#" / "frame::other:"). - if mode == "placed" then - local instance = CreateIndicatorInstance(auraName, typeKey) - if instance then - expandedCards["placed:" .. PoolKeyPrefix() .. auraName .. "#" .. instance.id] = true - end - else - EnsureTypeConfig(auraName, typeKey) - expandedCards["frame:" .. typeKey .. ":" .. PoolKeyPrefix() .. auraName] = true - end - -- Structural change: drive the LIVE frames, not just the editor. The callers only run - -- RefreshPlacedIndicators / RefreshPreviewEffects (editor chips + preview canvas), so - -- without this a freshly added indicator never builds its live container until some other - -- action (move / eye toggle / reload) fires ForceRefreshAllFrames. Mirrors AddSpellToGroup. - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end -end - --- ── ADD TO LAYOUT GROUP ("group" picker context) ── --- One click on a row's Icon/Square button (or the ID row's, for add-by-ID): --- create a NEW placed indicator of that type and enrol it in the target group. --- The picker stays open for multi-add, and the same spell can be added again — --- every add mints a fresh indicator id, so AddGroupMember's (auraName, --- indicatorID) dedup never blocks it. skipEcho lets add-by-ID substitute its --- own unknown-ID echo. Refresh chain mirrors the group card's member ✕. -local function AddSpellToGroup(groupID, auraName, display, typeKey, skipEcho, picker) - if not groupID then return end - local instance = CreateIndicatorInstance(auraName, typeKey) - if not instance then return end - AddGroupMember(groupID, auraName, instance.id) - S.adPickerDirty = true -- layout tab behind the picker is stale; rebuilt on close - if not skipEcho and picker then - local typeLabel = S.PLACED_TYPE_LABELS[typeKey] or typeKey - picker:Echo(format(L["Added %s."], - format("%s (%s)", display or auraName, typeLabel))) - end - RefreshPlacedIndicators() - -- Structural change: same full refresh as the member ✕ - -- (new indicator container + group positions + buff-row dedup). - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end -end - --- ── ADD BY ID (shared picker ID row) ── --- Snap known ids to their pool/curated record (then behave exactly like --- clicking that spell's row — same AddPickedSpell / AddSpellToGroup paths); --- unknown ids become an ad-hoc "#" aura whose key IS its identity --- (S.CleanupAdHocAura drops the config again once its last effect is --- removed). The picker stays open (echo confirms), so several ids can be --- added in a row. The shared picker has already validated the digits and --- normalized leading zeros; idText is that validated digit STRING. Returns --- truthy when the add landed (the picker clears its ID box on that). -local function ADAddByID(idNum, idText, picker, mode, typeKey, groupID) - local isOther = IsOtherTab() - local spec = ResolveSpec() - -- The Other Buffs pool is spec-independent — no spec required there. - if not spec and not isOther then return end - - -- Snap. My Buffs: the spec's curated tables first (alt ids included), - -- then the SpellDB (R.ByID indexes canonical + alt ids), else ad-hoc. - -- Other Buffs: SpellDB ONLY — the B1 naming contract (other-pool keys - -- are SpellDB rec.n or ad-hoc "#"; curated internal names don't - -- resolve with a nil spec). - local auraName - if not isOther then - local alts = DF.AuraDesigner.AlternateSpellIDs and DF.AuraDesigner.AlternateSpellIDs[spec] - if alts and alts[idNum] then auraName = alts[idNum] end - if not auraName then - local specIDs = DF.AuraDesigner.SpellIDs and DF.AuraDesigner.SpellIDs[spec] - if specIDs then - for name, id in pairs(specIDs) do - if id == idNum then auraName = name; break end - if type(id) == "table" then -- rare multi-id entries - for _, sub in ipairs(id) do - if sub == idNum then auraName = name; break end - end - if auraName then break end - end - end - end - end - end - if not auraName then - local R = DF.FilterRegistry - local rec = R and R.ByID and R.ByID[idNum] - if rec then auraName = rec.n end - end - - local isAdHoc = not auraName - -- Key from the validated TEXT, not tonumber output — number formatting - -- must never leak into config keys (AdHocSpellID parses "^#(%d+)$"). - if isAdHoc then auraName = "#" .. idText end - - -- Cross-tab block (B2): the spell — snapped name's FULL identity set, - -- or the raw id for ad-hoc — is already tracked by the OPPOSITE pool. - -- Checked before the group branch so group adds are blocked too. - local crossBlocked = false - if S.spellPickerBlockedIDs and next(S.spellPickerBlockedIDs) then - if S.spellPickerBlockedIDs[idNum] then - crossBlocked = true - elseif not isAdHoc then - crossBlocked = IsCandidateCrossBlocked(auraName, spec) - end - end - if crossBlocked then - picker:Echo(isOther and L["Already tracked in My Buffs."] or L["Already tracked in Any Buff."]) - return - end - - -- Display name: the trackable pool entry when it has one (curated - -- display or localized SpellDB name), else the raw key. - local display = auraName - if not isAdHoc then - if isOther then - display = OtherPoolDisplayName(auraName) - else - local trackable = Adapter and Adapter:GetTrackableAuras(spec) - if trackable then - for _, info in ipairs(trackable) do - if info.name == auraName then display = info.display or auraName; break end - end - end - end - end - - -- Group context: no already-used gate (a spell can hold several - -- indicators in one group). AddSpellToGroup echoes and refreshes. - if mode == "group" then - AddSpellToGroup(groupID, auraName, display, typeKey or "icon", isAdHoc, picker) - if isAdHoc then - picker:Echo(format(L["Added #%d as an unknown spell ID — name and icon will show if the ID is valid."], idNum)) - picker:RefreshRecords() -- the new ad-hoc aura gets a row of its own - end - return true - end - - local alreadyUsed - if mode == "placed" then - alreadyUsed = IsAuraTypePlaced(auraName, typeKey) - else - alreadyUsed = HasFrameEffect(auraName, typeKey) - end - if alreadyUsed then - picker:Echo(L["Already added."]) - return - end - - AddPickedSpell(auraName, typeKey, mode) - S.adPickerDirty = true - if isAdHoc then - picker:Echo(format(L["Added #%d as an unknown spell ID — name and icon will show if the ID is valid."], idNum)) - else - picker:Echo(format(L["Added %s."], display)) - end - picker:Refresh() -- the row flips to its blocked state - RefreshPlacedIndicators() -- live preview updates behind the picker - RefreshPreviewEffects() - return true -end - --- ── OPEN: ADD INDICATOR (placed + frame contexts) ── --- typeKey: "icon"|"square"|"bar" (placed) or "border"|"healthbar"|etc. --- (frame); mode: "placed" or "frame". Single-pick — choosing a spell --- creates the indicator (or frame-level type config), closes the picker --- and lands on its expanded effect card. My Buffs locks the picker to the --- resolved spec's class (class dropdown hidden, records limited to the --- class + "ALL"); Other Buffs offers the full database with both filters. -local function OpenIndicatorPicker(typeKey, mode) - local isOther = IsOtherTab() - local spec = (not isOther) and ResolveSpec() or nil - local specInfo = spec and DF.AuraDesigner.SpecInfo[spec] - local badgeColor = BADGE_COLORS[typeKey] or BADGE_COLORS.icon - local title - if mode == "frame" then - title = format(L["Select trigger for %s"], S.FRAME_LEVEL_LABELS[typeKey] or typeKey) - else - title = L["Select a spell"] - end - OpenADPicker({ - title = title, - subtitle = S.PLACED_TYPE_LABELS[typeKey] or S.FRAME_LEVEL_LABELS[typeKey] or typeKey, - subtitleColor = badgeColor, - records = function() return BuildADPickerRecords(false) end, - classLock = (not isOther) and specInfo and specInfo.class or nil, - isBlocked = function(rec) - local cross = ADCrossBlockText(rec) - if cross then return cross end - if mode == "placed" then - if IsAuraTypePlaced(rec.auraName, typeKey) then return L["Placed"] end - elseif HasFrameEffect(rec.auraName, typeKey) then - return L["Active"] - end - return nil - end, - rowActions = { - { - label = L["Add"], -- labels the ID-row button; rows are click-to-add - handler = function(rec, _, picker) - AddPickedSpell(rec.auraName, typeKey, mode) - picker:Close() - S.SwitchTab("effects") - RefreshPlacedIndicators() - RefreshPreviewEffects() - end, - }, - }, - allowAddByID = true, - onAddByID = function(idNum, _, picker, idText) - return ADAddByID(idNum, idText, picker, mode, typeKey, nil) - end, - }) -end - --- ── OPEN: ADD TO LAYOUT GROUP ── --- Every row carries Icon / Square buttons that create the indicator and --- enrol it in the target group in one click (the picker stays open for --- adding several in a row); the ID row gets the same two buttons, Enter --- defaulting to Icon. Records include the pool's configured ad-hoc --- "#" auras — the old picker offered them too. -local function OpenGroupSpellPicker(groupID) - local isOther = IsOtherTab() - local spec = (not isOther) and ResolveSpec() or nil - local specInfo = spec and DF.AuraDesigner.SpecInfo[spec] - local grp = groupID and GetLayoutGroupByID(groupID) - OpenADPicker({ - title = L["Select a spell"], - subtitle = grp and grp.name or "", - subtitleColor = GetThemeColor(), - records = function() return BuildADPickerRecords(true) end, - classLock = (not isOther) and specInfo and specInfo.class or nil, - -- Duplicates are allowed (each add is its own indicator instance), - -- so only the cross-tab block dims a row. - isBlocked = ADCrossBlockText, - rowActions = { - { - label = S.PLACED_TYPE_LABELS.icon or "Icon", - color = BADGE_COLORS.icon, - typeKey = "icon", - handler = function(rec, _, picker) - AddSpellToGroup(groupID, rec.auraName, rec.display, "icon", false, picker) - end, - }, - { - label = S.PLACED_TYPE_LABELS.square or "Square", - color = BADGE_COLORS.square, - typeKey = "square", - handler = function(rec, _, picker) - AddSpellToGroup(groupID, rec.auraName, rec.display, "square", false, picker) - end, - }, - }, - allowAddByID = true, - onAddByID = function(idNum, action, picker, idText) - return ADAddByID(idNum, idText, picker, "group", (action and action.typeKey) or "icon", groupID) - end, - }) -end -P.OpenGroupSpellPicker = OpenGroupSpellPicker - --- ── CREATE EFFECT CARD ── --- Creates a collapsible card for one effect in the effects list. --- Returns the new yPos after the card. -S.CreateEffectCard = function(parent, yPos, effect) - local isPlaced = (effect.source == "placed") - -- B1 key scheme: the pool prefix rides the NAME segment, so the two - -- pools' expandedCards entries can never collide. - local keyPrefix = PoolKeyPrefix() - local cardKey - if isPlaced then - cardKey = "placed:" .. keyPrefix .. effect.auraName .. "#" .. effect.indicatorID - else - cardKey = "frame:" .. effect.typeKey .. ":" .. keyPrefix .. effect.auraName - end - - local isExpanded = expandedCards[cardKey] or false - - -- ── CARD + HEADER ── - local card, header, chevron = CreateCardShell(parent, { - yPos = yPos, - expanded = isExpanded, - borderColor = {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}, - }) - - -- Spell icon (small, before type badge). Other-pool records resolve - -- icon/identity spec-independently (nil spec → ad-hoc / SpellDB fallback). - local spec = IsOtherTab() and nil or ResolveSpec() - local iconTex = GetAuraIcon(spec, effect.auraName) - local spellIcon = header:CreateTexture(nil, "ARTWORK") - spellIcon:SetSize(20, 20) - spellIcon:SetPoint("LEFT", chevron, "RIGHT", 6, 0) - if iconTex then - spellIcon:SetTexture(iconTex) - spellIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) - else - -- Color swatch fallback using aura color - local trackable3 = spec and Adapter and Adapter:GetTrackableAuras(spec) - local auraColor = nil - if trackable3 then - for _, ai in ipairs(trackable3) do - if ai.name == effect.auraName then auraColor = ai.color; break end - end - end - if auraColor then - spellIcon:SetColorTexture(auraColor[1] * 0.5, auraColor[2] * 0.5, auraColor[3] * 0.5, 1) - else - spellIcon:SetColorTexture(0.25, 0.25, 0.25, 1) - end - end - - -- Type badge - local badgeColor = BADGE_COLORS[effect.typeKey] or BADGE_COLORS.icon - local typeLabel = isPlaced - and (S.PLACED_TYPE_LABELS[effect.typeKey] or effect.typeKey) - or (S.FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey) - - local badgeBg = CreateFrame("Frame", nil, header, "BackdropTemplate") - badgeBg:SetHeight(16) - badgeBg:SetPoint("LEFT", spellIcon, "RIGHT", 4, 0) - ApplyBackdrop(badgeBg, - {r = badgeColor.r * 0.20, g = badgeColor.g * 0.20, b = badgeColor.b * 0.20, a = 1}, - {r = badgeColor.r * 0.45, g = badgeColor.g * 0.45, b = badgeColor.b * 0.45, a = 0.8}) - - local badgeText = badgeBg:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(badgeText, 8, "OUTLINE") - badgeText:SetPoint("CENTER", 0, 0) - badgeText:SetText(typeLabel) - badgeText:SetTextColor(1, 1, 1) - badgeBg:SetWidth(max(badgeText:GetStringWidth() + 12, 32)) - - -- Warning badge for auras with API-level tracking limitations - -- (positioned to the right of the type badge) - local warnKey = GetAuraWarningKey(spec, effect.auraName) - AttachWarningBadge(header, warnKey, { - point = "LEFT", - relativeTo = badgeBg, - relativePoint = "RIGHT", - offsetX = 4, - offsetY = 0, - size = 16, - }) - - -- Aura name + anchor/trigger/group info - local infoStr = effect.displayName - local indicatorGroup = nil -- layout group this indicator belongs to - if isPlaced then - indicatorGroup = GetIndicatorLayoutGroup(effect.auraName, effect.indicatorID) - if indicatorGroup then - infoStr = infoStr .. " - " .. indicatorGroup.name - elseif effect.anchor then - infoStr = infoStr .. " - " .. (OPTS.ANCHOR_OPTIONS[effect.anchor] or effect.anchor) - end - else - -- Show trigger count for frame-level effects - local triggers = GetFrameEffectTriggers(effect.auraName, effect.typeKey) - if #triggers > 1 then - -- No "(AND)" suffix: the operator toggle is gone (12.1 cannot evaluate - -- triggers together read-free), so multiple triggers always mean ANY/OR. - infoStr = infoStr .. " - " .. format(L["+%d triggers"], #triggers - 1) - end - end - -- Other Buffs: surface the per-effect Others Only state on the collapsed - -- header (prototype's "Others only" chip, as a text suffix). - if IsOtherTab() and effect.config and effect.config.othersOnly then - infoStr = infoStr .. " - " .. L["Others Only"] - end - local infoText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - if warnKey and header.dfWarningBadge and header.dfWarningBadge:IsShown() then - infoText:SetPoint("LEFT", header.dfWarningBadge, "RIGHT", 6, 0) - else - infoText:SetPoint("LEFT", badgeBg, "RIGHT", 6, 0) - end - -- Right inset clears the action icons: eye only (grouped) or eye + ✕. - infoText:SetPoint("RIGHT", header, "RIGHT", indicatorGroup and -30 or -52, 0) - infoText:SetMaxLines(1) - infoText:SetText(infoStr) - if indicatorGroup then - -- Use dimmed text for grouped indicators — they're managed by the group - infoText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - else - infoText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - - -- Delete button — hidden for grouped indicators (managed by layout group) - local delBtn - if not indicatorGroup then - delBtn = GUI:CreateCloseButton(header, { - size = 22, - onClick = function() - if isPlaced then - RemoveIndicatorInstance(effect.auraName, effect.indicatorID) - else - local auraCfg = CurrentAuraPool()[effect.auraName] - if auraCfg then auraCfg[effect.typeKey] = nil end - S.CleanupAdHocAura(effect.auraName) -- drop emptied ad-hoc "#" entries - end - expandedCards[cardKey] = nil - S.SwitchTab("effects") - RefreshPlacedIndicators() - RefreshPreviewEffects() - -- Structural change: the container must rebuild AND the buff-row - -- dedup union shrinks (deleted = no longer tracked), so run the - -- full refresh path (mirror the eye toggle) — without this the - -- deleted indicator's buff-row icon stays suppressed until an - -- unrelated rebuild. - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end, - }) - delBtn:SetPoint("RIGHT", -4, 0) - delBtn:SetFrameLevel(header:GetFrameLevel() + 2) - end - - -- Eye icon (visibility toggle) — left of the ✕; grouped indicators keep it - -- even though their ✕ is hidden. Asset + toggle idiom mirror Text Designer's - -- eye (TextDesigner/Options.lua): visibility / visibility_off from - -- Media/Icons, bright when shown, dim when hidden, hover brighten. - -- State lives on the raw config table: enabled == false is hidden; - -- nil/true (legacy records) is shown. - do - local cfgTable = effect.config - local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) - if delBtn then - eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) - else - eyeBtn:SetPoint("RIGHT", header, "RIGHT", -6, 0) - end - local function shown() return not cfgTable or cfgTable.enabled ~= false end - -- SetGlyph makes the state colour the new REST colour, so OnLeave - -- restores the state; hover is suppressed while hidden. - local function updateEyeIcon() - if shown() then - eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) - else - eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) - end - eyeBtn:SetGlyphHover(shown()) - end - updateEyeIcon() - eyeBtn:RegisterForClicks("LeftButtonUp") - eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) - eyeBtn:SetScript("OnClick", function() - if not cfgTable then return end - cfgTable.enabled = (cfgTable.enabled == false) and true or false - updateEyeIcon() - -- Sound rides the same flag as its "Enable Sound Alert" checkbox — - -- stop a playing alert immediately when hidden (mirror that checkbox). - if effect.typeKey == "sound" and cfgTable.enabled == false - and DF.AuraDesigner.SoundEngine then - DF.AuraDesigner.SoundEngine:StopAura(effect.auraName) - end - -- Structural change: the factory must tear down / stand up the - -- container and the buff-row dedup union changes (hidden = not - -- tracked), so run the full refresh path (mirror the enable toggle). - S.SwitchTab("effects") - RefreshPlacedIndicators() - RefreshPreviewEffects() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end) - - -- Hidden rows dim (name/icon), like Text Designer's disabled elements - if not shown() then - spellIcon:SetAlpha(0.4) - infoText:SetAlpha(0.5) - end - end - - -- Header click → toggle expansion - header:SetScript("OnClick", function() - expandedCards[cardKey] = not expandedCards[cardKey] - S.SwitchTab("effects") - end) - header:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - header:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - local totalCardH = 30 - - -- ── BODY (only when expanded) ── - if isExpanded then - local body = CreateFrame("Frame", nil, card, "BackdropTemplate") - body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) - body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) - ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, - {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3}) - - -- Create the appropriate proxy - local proxy - if isPlaced then - proxy = CreateInstanceProxy(effect.auraName, effect.indicatorID) - else - proxy = CreateProxy(effect.auraName, effect.typeKey) - end - - -- Build type-specific widgets (derive width from parent scroll frame) - local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 - if bodyWidth < 100 then bodyWidth = 240 end - - local triggersH = 0 - - -- ── TRIGGER TAGS (frame-level effects only) ── - if not isPlaced then - local triggers = GetFrameEffectTriggers(effect.auraName, effect.typeKey) - local trigContainer = CreateFrame("Frame", nil, body) - trigContainer:SetPoint("TOPLEFT", 8, -12) - trigContainer:SetPoint("RIGHT", body, "RIGHT", -8, 0) - - local trigLabel = trigContainer:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(trigLabel, 9, "") - trigLabel:SetPoint("TOPLEFT", 0, 0) - trigLabel:SetText(L["TRIGGERED BY"]) - trigLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- AND/OR operator toggle (only shown with 2+ triggers) - -- (No multi-trigger ALL/ANY operator button: evaluating every trigger together - -- needs a read the 12.1 aura system cannot do for secret-anchored triggers, so - -- it was permanently frosted. Removed 2026-07-25 -- triggerOperator was never - -- read by the render path either, only by this editor's own label, so the - -- toggle changed nothing. Triggers combine as ANY/OR. The tags stay editable.) - - -- Build display name lookup for tags. Other-pool trigger names are - -- SpellDB names / ad-hoc keys — resolved live per tag below. - local isOtherCard = IsOtherTab() - local spec = (not isOtherCard) and ResolveSpec() or nil - local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) - local displayNames = {} - if trackable then - for _, info in ipairs(trackable) do - displayNames[info.name] = info.display - end - end - if isOtherCard then - setmetatable(displayNames, { __index = function(_, name) - return OtherPoolDisplayName(name) - end }) - end - - -- Tag flow layout - local TAG_H = 20 - local TAG_GAP = 4 - local TAG_ROW_GAP = 3 - local tagX, tagY = 0, -(14 + 6) -- below label - local canRemove = #triggers > 1 - - for ti, trigName in ipairs(triggers) do - local tagFrame = CreateFrame("Frame", nil, trigContainer, "BackdropTemplate") - tagFrame:SetHeight(TAG_H) - - local tagText = tagFrame:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(tagText, 9, "") - tagText:SetPoint("LEFT", 6, 0) - tagText:SetText(displayNames[trigName] or trigName) - tagText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local tagW = tagText:GetStringWidth() + 12 - if canRemove then tagW = tagW + 16 end -- room for × button - tagW = max(tagW, 40) - - -- Wrap to next row if needed - local containerW = trigContainer:GetWidth() - if containerW < 50 then containerW = bodyWidth - 16 end - if tagX > 0 and (tagX + tagW) > containerW then - tagX = 0 - tagY = tagY - (TAG_H + TAG_ROW_GAP) - end - - tagFrame:SetPoint("TOPLEFT", trigContainer, "TOPLEFT", tagX, tagY) - tagFrame:SetWidth(tagW) - ApplyBackdrop(tagFrame, - {r = 0.14, g = 0.14, b = 0.17, a = 1}, - {r = 0.30, g = 0.30, b = 0.35, a = 0.8}) - - -- Remove × button on each tag (unless it's the last one) - if canRemove then - local capturedTrigName = trigName - -- Shared red-at-rest "×" (tone="danger") on each removable tag. - local removeBtn = DF.GUI:CreateCloseButton(tagFrame, { - size = 14, - tone = "danger", - onClick = function() - RemoveFrameEffectTrigger(effect.auraName, effect.typeKey, capturedTrigName) - S.SwitchTab("effects") - RefreshPreviewEffects() - end, - }) - removeBtn:SetPoint("RIGHT", -2, 0) - end - - tagX = tagX + tagW + TAG_GAP - end - - -- "+ Add Trigger" button - local addTrigW = 80 - if tagX > 0 and (tagX + addTrigW) > (bodyWidth - 16) then - tagX = 0 - tagY = tagY - (TAG_H + TAG_ROW_GAP) - end - local addTrigBtn = CreateFrame("Button", nil, trigContainer, "BackdropTemplate") - addTrigBtn:SetPoint("TOPLEFT", trigContainer, "TOPLEFT", tagX, tagY) - GUI:StyleButton(addTrigBtn, { width = addTrigW, height = TAG_H, primary = true, accent = { r = 0.25, g = 0.40, b = 0.25 }, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 11 }, text = L["Add Trigger"] }) - GUI:SetSettingsFont(addTrigBtn.Text, 9, "") - addTrigBtn.Text:SetTextColor(0.5, 0.8, 0.5) - addTrigBtn.Icon:SetVertexColor(0.5, 0.8, 0.5) - - -- Trigger picker: the shared spell database picker, single-pick. - -- My Buffs locks it to the resolved spec's class; Other Buffs - -- offers the full database (the old plain dropdown restricted - -- Other-tab triggers to already-configured auras only because - -- the full DB was unusable without search/filters — the shared - -- picker has both, so the restriction is lifted). Records also - -- include the pool's configured ad-hoc "#" auras. Rows - -- already in the effect's trigger list render with the dimmed - -- check ("already added"). - addTrigBtn:SetScript("OnClick", function() - -- Pin the pool at OPEN time (pool pinning carried over from - -- the old floating dropdown): a pick must keep writing the - -- trigger into the pool this card's record lives in, no - -- matter how the surrounding UI state moves while the - -- picker is up (the record exists, so the read accessor - -- returns the real table, never EMPTY_POOL). - local capturedPool = CurrentAuraPool() - local isOtherTrig = IsOtherTab() - local trigSpec = (not isOtherTrig) and ResolveSpec() or nil - local trigSpecInfo = trigSpec and DF.AuraDesigner.SpecInfo[trigSpec] - - local currentTriggers = GetFrameEffectTriggers(effect.auraName, effect.typeKey) - local trigLookup = {} - for _, t in ipairs(currentTriggers) do trigLookup[t] = true end - - OpenADPicker({ - title = format(L["Select trigger for %s"], S.FRAME_LEVEL_LABELS[effect.typeKey] or effect.typeKey), - subtitle = effect.displayName, - records = function() return BuildADPickerRecords(true) end, - classLock = (not isOtherTrig) and trigSpecInfo and trigSpecInfo.class or nil, - isBlocked = function(rec) - return trigLookup[rec.auraName] and true or nil - end, - rowActions = { - { - handler = function(rec, _, picker) - AddFrameEffectTrigger(effect.auraName, effect.typeKey, rec.auraName, capturedPool) - picker:Close() - S.SwitchTab("effects") - RefreshPreviewEffects() - end, - }, - }, - }) - end) - - triggersH = -(tagY) + TAG_H + 8 -- total height of trigger section - trigContainer:SetHeight(triggersH) - - -- Border mode toggle (border effects only) - if effect.typeKey == "border" then - local auraCfgBM = CurrentAuraPool()[effect.auraName] - local typeCfgBM = auraCfgBM and auraCfgBM[effect.typeKey] - local isCustom = typeCfgBM and typeCfgBM.borderMode == "custom" - - local bmContainer = CreateFrame("Frame", nil, body) - bmContainer:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 10)) - bmContainer:SetPoint("RIGHT", body, "RIGHT", -8, 0) - bmContainer:SetHeight(26) - - local bmLabel = bmContainer:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(bmLabel, 9, "") - bmLabel:SetPoint("LEFT", 0, 0) - bmLabel:SetText(L["Border Mode:"]) - bmLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Shared button - local sharedBtn = CreateFrame("Button", nil, bmContainer, "BackdropTemplate") - sharedBtn:SetPoint("LEFT", bmLabel, "RIGHT", 6, 0) - - local sharedText = sharedBtn:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(sharedText, 9, "") - sharedText:SetPoint("CENTER", 0, 0) - sharedText:SetText(L["Shared"]) - local sharedW = sharedText:GetStringWidth() + 16 - if sharedW < 50 then sharedW = 50 end - -- Shared styler: rest + accent-wash hover + SetActive selection state. - -- Keep the manual (small) label; size to the computed text width. - GUI:StyleButton(sharedBtn, { width = sharedW, height = 20 }) - - -- Custom button - local customBtn = CreateFrame("Button", nil, bmContainer, "BackdropTemplate") - customBtn:SetPoint("LEFT", sharedBtn, "RIGHT", 4, 0) - - local customText = customBtn:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(customText, 9, "") - customText:SetPoint("CENTER", 0, 0) - customText:SetText(L["Custom"]) - local customW = customText:GetStringWidth() + 16 - if customW < 50 then customW = 50 end - GUI:StyleButton(customBtn, { width = customW, height = 20 }) - - -- Drive the selection state via the shared styler's SetActive (active = - -- toned accent border + subtle accent fill). Keep a bright/dim label cue. - local function StyleBorderModeButtons(customActive) - sharedBtn:SetActive(not customActive) - customBtn:SetActive(customActive) - if customActive then - customText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - sharedText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - else - sharedText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - customText:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - StyleBorderModeButtons(isCustom) - - sharedBtn:SetScript("OnClick", function() - local cfg = EnsureTypeConfig(effect.auraName, effect.typeKey) - cfg.borderMode = nil -- shared is default - S.SwitchTab("effects") - RefreshPreviewEffects() - end) - customBtn:SetScript("OnClick", function() - local cfg = EnsureTypeConfig(effect.auraName, effect.typeKey) - cfg.borderMode = "custom" - S.SwitchTab("effects") - RefreshPreviewEffects() - end) - - -- Tooltips via HookScript so they compose with the styler's hover wash. - sharedBtn:HookScript("OnEnter", function() - GUI:ShowTooltip(sharedBtn, { - title = L["Shared Border"], - lines = { - L["Uses a single border per frame. Highest priority wins."], - }, - }) - end) - sharedBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) - customBtn:HookScript("OnEnter", function() - GUI:ShowTooltip(customBtn, { - title = L["Custom Border"], - lines = { - L["Gets its own independent border overlay. Multiple custom borders can be visible at the same time."], - }, - }) - end) - customBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) - - triggersH = triggersH + 36 - end - - -- Priority slider (frame-level effects only — resolves conflicts when - -- multiple auras set the same frame effect, e.g. two health bar colors) - local auraProxy = CreateAuraProxy(effect.auraName) - local priSlider = GUI:CreateSlider(body, L["Priority"], 1, 10, 1, auraProxy, "priority") - -- Extra gap above (was +4) so the slider isn't squished against the - -- triggers / Add Trigger row, plus a little breathing room below before - -- the effect's Appearance group (increment 54 → 68 → 84 with the note). - -- x=8 matches the "TRIGGERED BY" section above (Options.lua trigContainer) - -- so the Priority slider + note line up with the card's other elements. - priSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 14)) - priSlider:SetWidth(bodyWidth - 16) - -- Direction note in the standard GUI label style (dim, wrapped) so it - -- matches every other settings note: HIGHER number = higher priority. - local priNote = GUI:CreateLabel(body, L["Higher priority wins"], bodyWidth - 16) - priNote:SetPoint("TOPLEFT", priSlider, "BOTTOMLEFT", 0, -2) - triggersH = triggersH + 84 - end - - -- ── OTHERS ONLY (Other Buffs tab; placed AND frame-level effects) ── - -- Not offered for sound: the on-apply sound path has no caster filter - -- (the sound card carries an explanatory banner instead, see - -- BuildTypeContent). Writes instance.othersOnly / typeCfg.othersOnly - -- through the pool-pinned proxy; the filter string ("HELPFUL|!PLAYER") - -- binds at container build, so toggling is STRUCTURAL (B1 folds it - -- into every struct sig → the factory Rebuilds). - if IsOtherTab() and effect.typeKey ~= "sound" then - local ooCb = GUI:CreateCheckbox(body, L["Others Only"], proxy, "othersOnly", function() - DF:AuraDesigner_RefreshPage() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end) - ooCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 12)) - ooCb:SetWidth(bodyWidth - 16) - ooCb.tooltip = L["Only show this effect for other players' casts of the buff."] - triggersH = triggersH + 34 - end - - local _, bodyH = BuildTypeContent(body, effect.typeKey, effect.auraName, bodyWidth, proxy, triggersH, indicatorGroup, effect.indicatorID) - - -- Bottom collapse bar for the indicator card - local collapseBarH = 14 - local collapseBar = CreateFrame("Button", nil, body) - collapseBar:SetHeight(collapseBarH) - collapseBar:SetPoint("BOTTOMLEFT", body, "BOTTOMLEFT", 1, 1) - collapseBar:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -1, 1) - - local barBg = collapseBar:CreateTexture(nil, "BACKGROUND") - barBg:SetAllPoints() - barBg:SetColorTexture(1, 1, 1, 0.03) - - local barIcon = collapseBar:CreateTexture(nil, "OVERLAY") - barIcon:SetSize(8, 8) - barIcon:SetPoint("CENTER", 0, 0) - barIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") - barIcon:SetVertexColor(1, 1, 1, 0.3) - - collapseBar:SetScript("OnEnter", function() - barBg:SetColorTexture(1, 1, 1, 0.06) - barIcon:SetVertexColor(1, 1, 1, 0.6) - end) - collapseBar:SetScript("OnLeave", function() - barBg:SetColorTexture(1, 1, 1, 0.03) - barIcon:SetVertexColor(1, 1, 1, 0.3) - end) - collapseBar:SetScript("OnClick", function() - expandedCards[cardKey] = false - S.SwitchTab("effects") - end) - - local contentH = (bodyH or 50) + triggersH + collapseBarH - body:SetHeight(contentH) - totalCardH = totalCardH + contentH - end - - card:SetHeight(totalCardH) - return yPos - totalCardH - 5 -end - --- ── BUILD EFFECTS TAB ── -S.BuildEffectsTab = function() - if not S.tabContentFrame then return end - local parent = S.tabContentFrame - local yPos = -10 - local tc = GetThemeColor() - - -- "+ Add Indicator" button (prominent, theme-colored border) - local addBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - addBtn:SetHeight(32) - addBtn:SetPoint("TOPLEFT", 8, yPos) - addBtn:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - -- Shared primary CTA: accent fill + white label via the styler (was a bespoke - -- fontstring, which is why AD's and TD's hero labels didn't match). - GUI:StyleButton(addBtn, { height = 32, primary = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 14 }, text = L["Add Indicator"], font = "DFFontHighlight" }) - - -- Dropdown menu for add button - local menuFrame = CreateFrame("Frame", nil, addBtn, "BackdropTemplate") - menuFrame:SetPoint("TOPLEFT", addBtn, "BOTTOMLEFT", 0, -2) - menuFrame:SetPoint("TOPRIGHT", addBtn, "BOTTOMRIGHT", 0, -2) - menuFrame:SetFrameStrata("DIALOG") - menuFrame:SetFrameLevel(100) - ApplyBackdrop(menuFrame, {r = 0.10, g = 0.10, b = 0.10, a = 0.98}, C_BORDER) - menuFrame:Hide() - menuFrame:EnableMouse(true) - - local PLACED_ITEMS = { - { label = L["Icon"], type = "icon" }, - { label = L["Square"], type = "square" }, - { label = L["Bar"], type = "bar" }, - } - local FRAME_ITEMS = { - { label = L["Border"], type = "border" }, - { label = L["Health Bar Color"], type = "healthbar" }, - { label = L["Background Color"], type = "background" }, - { label = L["Name Text Color"], type = "nametext" }, - { label = L["Health Text Color"], type = "healthtext" }, - { label = L["Sound Alert"], type = "sound" }, - } - - local my = -4 - local MENU_ROW_H = 24 - - -- The menu is two identical sections -- a small dim heading, then one row - -- per entry coloured by its type badge -- and both were written out in - -- full. `my` is the running cursor, so these close over it. - local function AddMenuSection(heading, items, scope) - local hdr = menuFrame:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(hdr, 9, "") - hdr:SetPoint("TOPLEFT", 10, my) - hdr:SetText(heading) - hdr:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - my = my - 14 - - for _, item in ipairs(items) do - local menuBtn = CreateFrame("Button", nil, menuFrame) - menuBtn:SetHeight(MENU_ROW_H) - menuBtn:SetPoint("TOPLEFT", 4, my) - menuBtn:SetPoint("RIGHT", menuFrame, "RIGHT", -4, 0) - local bc = BADGE_COLORS[item.type] - local lbl = menuBtn:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(lbl, 10, "") - lbl:SetPoint("LEFT", 8, 0) - lbl:SetText(item.label) - lbl:SetTextColor(bc.r, bc.g, bc.b) - local hl = menuBtn:CreateTexture(nil, "HIGHLIGHT") - hl:SetAllPoints() - hl:SetColorTexture(1, 1, 1, 0.05) - local capturedType = item.type - menuBtn:SetScript("OnClick", function() - menuFrame:Hide() - OpenIndicatorPicker(capturedType, scope) - end) - my = my - MENU_ROW_H - end - end - - AddMenuSection(L["PLACED ON FRAME"], PLACED_ITEMS, "placed") - - -- Divider - my = my - 4 - local mdiv = menuFrame:CreateTexture(nil, "ARTWORK") - mdiv:SetPoint("TOPLEFT", 8, my) - mdiv:SetPoint("RIGHT", menuFrame, "RIGHT", -8, 0) - mdiv:SetHeight(1) - mdiv:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.6) - my = my - 6 - - AddMenuSection(L["FRAME-LEVEL EFFECTS"], FRAME_ITEMS, "frame") - - menuFrame:SetHeight(-my + 6) - - addBtn:SetScript("OnClick", function() - if menuFrame:IsShown() then - menuFrame:Hide() - else - menuFrame:Show() - end - end) - - yPos = yPos - 44 - - -- ── ACTIVE INDICATORS heading ── - local activeHeader = parent:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(activeHeader, 9, "") - activeHeader:SetPoint("TOPLEFT", 8, yPos) -- align with chips/cards/add button - activeHeader:SetText(L["ACTIVE INDICATORS"]) - activeHeader:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - yPos = yPos - 16 - - -- ── FILTER CHIPS (wrapping layout) ── - local chipsFrame = CreateFrame("Frame", nil, parent) - chipsFrame:SetPoint("TOPLEFT", 8, yPos) - chipsFrame:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - - local FILTER_CHIPS = { - { key = "all", label = L["All"] }, - { key = "icon", label = L["Icon"] }, - { key = "square", label = L["Square"] }, - { key = "bar", label = L["Bar"] }, - { key = "border", label = L["Border"] }, - { key = "healthbar", label = L["Health"] }, - { key = "nametext", label = L["Name"] }, - { key = "healthtext", label = L["HP"] }, - } - - local CHIP_H = 22 - local CHIP_GAP = 4 - local CHIP_ROW_GAP = 4 - local chipBtns = {} - - for _, chip in ipairs(FILTER_CHIPS) do - local chipBtn = CreateFrame("Button", nil, chipsFrame, "BackdropTemplate") - chipBtn:SetHeight(CHIP_H) - - local chipTxt = chipBtn:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(chipTxt, 10, "OUTLINE") - chipTxt:SetPoint("CENTER", 0, 0) - chipTxt:SetText(chip.label) - chipTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local tw = chipTxt:GetStringWidth() - chipBtn:SetWidth(max(tw + 16, 32)) - - -- Shared styling: standard hover + an active (selected) state marked by a - -- prominent accent border. The row rebuilds on click, so set active here. - GUI:StyleButton(chipBtn) - chipBtn:SetActive(S.activeFilter == chip.key) - - local capturedKey = chip.key - chipBtn:SetScript("OnClick", function() - S.activeFilter = capturedKey - S.SwitchTab("effects") - end) - - tinsert(chipBtns, chipBtn) - end - - -- Flow-layout: position chips with wrapping on parent resize - local function LayoutChips() - local maxW = chipsFrame:GetWidth() - if maxW < 20 then maxW = 260 end - local cx, cy = 0, 0 - for _, btn in ipairs(chipBtns) do - local bw = btn:GetWidth() - if cx > 0 and (cx + bw) > maxW then - cx = 0 - cy = cy - (CHIP_H + CHIP_ROW_GAP) - end - btn:ClearAllPoints() - btn:SetPoint("TOPLEFT", chipsFrame, "TOPLEFT", cx, cy) - cx = cx + bw + CHIP_GAP - end - chipsFrame:SetHeight(max(-cy + CHIP_H, CHIP_H)) - end - LayoutChips() - chipsFrame:SetScript("OnSizeChanged", LayoutChips) - - yPos = yPos - (chipsFrame:GetHeight() + 10) - - -- ── OTHER BUFFS HINT ── - if IsOtherTab() then - local obHint = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - obHint:SetPoint("TOPLEFT", 8, yPos) - obHint:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - obHint:SetJustifyH("LEFT") - obHint:SetWordWrap(true) - obHint:SetText(L["These indicators trigger no matter who casts the buff."]) - obHint:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) - yPos = yPos - (max(obHint:GetStringHeight(), 12) + 10) - end - - -- ── EFFECTS LIST ── - local effects = CollectAllEffects() - - -- Apply filter - local filtered = {} - for _, effect in ipairs(effects) do - if S.activeFilter == "all" or effect.typeKey == S.activeFilter then - tinsert(filtered, effect) - end - end - - if #filtered == 0 then - local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) - empty:SetWidth(220) - local spec = ResolveSpec() - local specAuras = spec and Adapter:GetTrackableAuras(spec) - -- The Other Buffs pool is spec-independent — never show the - -- unsupported-spec message there. - if not IsOtherTab() and (not spec or not specAuras or #specAuras == 0) then - empty:SetText(L["No trackable spells found for this spec.\n\nYou can select a different spec using the dropdown above."]) - elseif S.activeFilter == "all" then - empty:SetText(L["No effects configured yet.\nClick '+ Add Indicator' to get started."]) - else - empty:SetText(format(L["No %s effects configured."], (S.PLACED_TYPE_LABELS[S.activeFilter] or S.FRAME_LEVEL_LABELS[S.activeFilter] or S.activeFilter))) - end - empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) - empty:SetJustifyH("CENTER") - else - for _, effect in ipairs(filtered) do - yPos = S.CreateEffectCard(parent, yPos, effect) - end - end - - parent:SetHeight(max(-yPos + 20, 200)) -end - --- ── BUILD GLOBAL TAB ── --- Wraps the existing BuildGlobalView into the tab content frame -S.BuildGlobalTab = function() - if not S.tabContentFrame then return end - BuildGlobalView(S.tabContentFrame) -end - --- ── BUILD LAYOUT GROUPS TAB ── --- ============================================================ --- GROUP APPEARANCE SECTION (filter-group + debuff-group cards) --- Collapsible "Appearance" SettingsGroup (the effect-card section idiom) --- holding the per-group icon styling the container genuinely supports: --- cooldown swipe, border (full CreateBorderControls set incl. the DF-owned --- animations), duration text (show / font / scale / outline / anchor / --- offsets / colour-by-time / colour / hide-above) and stack count (show + --- text styling). Controls bind to group.style via a defaults proxy --- (CreateInstanceProxy's idiom): nil keys read the pre-style defaults, so --- an untouched section changes nothing — the factory renders a style-less --- (or all-default) group byte-identically to before. Omitted vs the placed --- effect card, by capability: Min Stacks (no formatter on the native stack --- path — secret trap), Hide Icon / size / scale / alpha / frame level --- (group-level layout already owns size; the rest are per-indicator --- concepts), Expiring / Show When Missing (remaining-time / presence reads). --- Structural fields (show toggles, colour-by-time, hide-above, border --- on/off) move the group struct sig -> the factory Rebuilds; everything --- else hot-applies via the cosmetic sig. Collapse state persists PER CARD --- under "adGroupStyle:" — cardKey is the caller's expand-key form --- (raw id / "othergroup:" / "dgroup:"), so the three stores' keys --- stay disjoint from each other and from the effect cards' header keys. -local function AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) - local s = group.style - if type(s) ~= "table" then s = {}; group.style = s end - - -- Defaults = today's uniform group rendering (Factory buildFilterGroupStyle's - -- pre-style values) + the icon indicator's Border* seeds so CreateBorderControls - -- reads sensible values on first open (ShowBorder overridden OFF — a group has - -- no ring until the user enables one). - local defaults = { - hideSwipe = false, showDuration = true, showStacks = true, - durationFormat = "NUMBER", - durationFont = "DF Roboto SemiBold", durationScale = 1.0, durationOutline = "SHADOW;OUTLINE", - durationAnchor = "CENTER", durationX = 0, durationY = 0, - durationColorByTime = false, durationColor = { r = 1, g = 1, b = 1, a = 1 }, - durationHideAboveEnabled = false, durationHideAboveThreshold = 10, - durationHideOnPermanent = true, -- Wave 4: absent key = ON (style-less identity) - stackFont = "DF Roboto SemiBold", stackScale = 1.0, stackOutline = "SHADOW;OUTLINE", - stackAnchor = "BOTTOMRIGHT", stackX = 2, stackY = -1, - stackColor = { r = 1, g = 1, b = 1, a = 1 }, - ShowBorder = false, - -- Duration bar strip (Wave 3) — mirrors the row pages' defaults - -- (Config.lua buffDurationBar*). OFF until the user enables it. - durationBarEnabled = false, durationBarPosition = "BOTTOM", - durationBarHeight = 4, durationBarGap = 1, durationBarColorMode = "STATIC", - durationBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", - durationBarColor = { r = 0.2, g = 0.9, b = 0.3, a = 1 }, - durationBarBGColor = { r = 0, g = 0, b = 0, a = 0.8 }, - durationBarReverseFill = false, - } - for k, v in pairs(TYPE_DEFAULTS.icon) do - if k:find("^Border") and defaults[k] == nil then defaults[k] = v end - end - - -- Defaults proxy (CreateInstanceProxy's idiom, group.style-backed): reads fall - -- through to the defaults (table fallbacks copy-on-read so colour sub-key edits - -- persist); writes land in group.style and refresh the live frames. The factory - -- reads the RAW style table with the same defaults, so UI and render agree. - local proxy = setmetatable({ _skipOverrideIndicators = true, __dfDefaults = defaults }, { - __index = function(_, k) - local val = s[k] - if val ~= nil then return val end - local fallback = defaults[k] - if type(fallback) == "table" then - local copy = {} - for fk, fv in pairs(fallback) do copy[fk] = fv end - s[k] = copy - return copy - end - return fallback - end, - __newindex = function(_, k, v) - s[k] = v - RefreshPlacedIndicators() - RefreshLiveFramesThrottled() - end, - }) - - -- Cosmetic edits hot-apply (coSig -> ApplyStyle); structural toggles move the - -- struct sig -> Rebuild. Both ride the same throttled factory re-sync. The - -- canvas placeholder's sample icons render the group style too, so every - -- appearance edit re-draws them (RefreshPlacedIndicators — the same direct - -- call the card's layout sliders run per edit/drag). - local function refresh() - RefreshPlacedIndicators() - RefreshLiveFramesThrottled() - end - -- Visibility changes inside the section (border style dropdown swapping its - -- widget set) re-measure heights, so rebuild the tab — the same full-rebuild - -- the effect cards' dropdown callbacks run (AuraDesigner_RefreshPage). - local function rebuildTab() S.SwitchTab("layout") end - - -- One collapsible box PER CATEGORY — the expanded effect card's section - -- structure (Appearance / Border / Duration Text / Stack Count, same names - -- and order as the icon card's AddGroup boxes; group-inapplicable sections - -- — Position, Show When Missing, Expiring — have no group-level analogue). - -- Collapse persists per card per section ("adGroupStyle::
"), - -- so each section toggles independently; the toggle rides the widget's - -- built-in AuraDesigner_RefreshPage rebuild like the effect cards'. - local function AddSection(header, sectionKey, buildFn) - local g = GUI:CreateSettingsGroup(body, bodyWidth - 10, { - collapsible = true, - collapseKey = "adGroupStyle:" .. tostring(cardKey) .. ":" .. sectionKey, - }) - g.padding = 10 -- match the main Options groups' inner padding (airier scale) - g:AddWidget(GUI:CreateHeader(body, header), GUI.RowHeight.sectionHeader) - buildFn(g) - local h = g:LayoutChildren() -- includes the group's own bottom margin - g:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - by = by - h - end - - -- ── APPEARANCE ── (the effect card's Appearance box; of its controls only - -- the swipe applies at group level — size/scale live in the card's layout - -- sliders, alpha/level/strata/text-only are per-indicator concepts) - AddSection(L["Appearance"], "appearance", function(g) - g:AddWidget(GUI:CreateCheckbox(body, L["Hide Cooldown Swipe"], proxy, "hideSwipe", refresh), 28) - end) - - -- ── BORDER ── (the placed icon's control set; gradient degrades to solid on - -- container slots — same known casualty as placed indicators; LCG glow types - -- are excluded from the animation dropdown, mirror the placed border) - AddSection(L["Border"], "border", function(g) - GUI:CreateBorderControls(g, proxy, "", { - parent = body, - include = { - inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true, - }, - fullUpdate = refresh, - lightUpdate = refresh, - lightColors = refresh, - refreshStates = rebuildTab, - sizeMin = 1, sizeMax = 5, sizeStep = 1, - }) - end) - - -- ── DURATION TEXT ── (shared text controls; keys mirror the placed cards') - AddSection(L["Duration Text"], "duration", function(g) - g:AddWidget(GUI:CreateCheckbox(body, L["Show Duration"], proxy, "showDuration", refresh), 28) - -- Icon-sized formats only — a group renders icon rows (see the placed icon - -- card's Duration Format note). Structural: the proxy write's refresh moves - -- durationFmtKey -> the factory Rebuilds. Forward-declared - -- UpdateHideAboveState (assigned below): re-greys Hide Above, which can't - -- compose with the percent-family formats. - local UpdateHideAboveState - g:AddWidget(GUI:CreateDropdown(body, L["Duration Format"], { - NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], - _order = { "NUMBER", "SHORT", "PERCENT" }, - }, proxy, "durationFormat", function() if UpdateHideAboveState then UpdateHideAboveState() end end), 54) - GUI:CreateTextControls(g, proxy, "duration", { - parent = body, - include = { color = true }, - colorLabel = L["Duration Text Color"], - colorDisableOn = function() return proxy.durationColorByTime and true or false end, - onChange = refresh, onDrag = refresh, - }) - g:AddWidget(GUI:CreateCheckbox(body, L["Color by Time Remaining"], proxy, "durationColorByTime", refresh), 28) - AddDurationColorsLink(g, body) - local hideAboveSlider, hideAboveCheck - UpdateHideAboveState = function() - if not hideAboveSlider then return end - local pctFmt = DF.IsPercentDurationFormat and DF:IsPercentDurationFormat(proxy.durationFormat) - if hideAboveCheck and hideAboveCheck.SetEnabled then hideAboveCheck:SetEnabled(not pctFmt) end - if not pctFmt and proxy.durationHideAboveEnabled then - hideAboveSlider:SetAlpha(1) - hideAboveSlider:EnableMouse(true) - else - hideAboveSlider:SetAlpha(0.4) - hideAboveSlider:EnableMouse(false) - end - end - hideAboveCheck = GUI:CreateCheckbox(body, L["Hide Duration Above Threshold"], proxy, "durationHideAboveEnabled", function() - UpdateHideAboveState() - refresh() - end) - g:AddWidget(hideAboveCheck, 28) - hideAboveSlider = GUI:CreateSlider(body, L["Hide Above (seconds)"], 1, 60, 1, proxy, "durationHideAboveThreshold", refresh, refresh, true) - g:AddWidget(hideAboveSlider, 54) - g:AddWidget(GUI:CreateCheckbox(body, L["Hide Duration on Permanent Auras"], proxy, "durationHideOnPermanent", refresh), 28) - UpdateHideAboveState() - end) - - -- ── STACK COUNT ── (no Min Stacks — not expressible on the native no-formatter - -- stack path, see Features/Auras.lua's stacks-formatter warning) - AddSection(L["Stack Count"], "stacks", function(g) - g:AddWidget(GUI:CreateCheckbox(body, L["Show Stacks"], proxy, "showStacks", refresh), 28) - GUI:CreateTextControls(g, proxy, "stack", { - parent = body, - include = { color = true }, - colorLabel = L["Stack Text Color"], - onChange = refresh, onDrag = refresh, - }) - end) - - -- ── DURATION BAR ── (Wave 3: strip below/above each icon, drained by the - -- native SetDurationBar fill — render-side, works on secret auras. The keys - -- mirror the row pages' buffDurationBar* block; enable/position/height/gap - -- are structural (group struct sig -> Rebuild), texture/colours hot-apply.) - AddSection(L["Duration Bar"], "durationbar", function(g) - -- Greys IMPERATIVELY, not via widget.disableOn: this is an AD editor card, which - -- has no disableOn/RefreshStates loop (that seam only runs on the SettingsGroup - -- row pages). The enable gate AND the curve-mode dimming of Texture/Bar Color are - -- driven by hand from the Enable + Color Mode callbacks. curveGated flags the two - -- controls a curve mode overrides. - local dbWidgets, curveGated = {}, {} - local function UpdateBarGrey() - local on = proxy.durationBarEnabled and true or false - local curve = DF:IsDurationBarCurveMode(proxy.durationBarColorMode) - for i = 1, #dbWidgets do - local w = dbWidgets[i] - local enable = on and not (curveGated[w] and curve) - if w.SetEnabled then w:SetEnabled(enable) - else - w:SetAlpha(enable and 1 or 0.4) - if w.EnableMouse then w:EnableMouse(enable) end - end - end - end - g:AddWidget(GUI:CreateCheckbox(body, L["Enable Duration Bar"], proxy, "durationBarEnabled", function() - UpdateBarGrey() - refresh() - end), 28) - local function barChild(widget, h) - g:AddWidget(widget, h) - dbWidgets[#dbWidgets + 1] = widget - return widget - end - barChild(GUI:CreateDropdown(body, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, proxy, "durationBarPosition", refresh), 54) - barChild(GUI:CreateSlider(body, L["Height"], 1, 12, 1, proxy, "durationBarHeight", refresh, refresh, true), 54) - barChild(GUI:CreateSlider(body, L["Gap"], 0, 10, 1, proxy, "durationBarGap", refresh, refresh, true), 54) - barChild(GUI:CreateDropdown(body, L["Color Mode"], - DF:GetDurationBarColorModes(), - proxy, "durationBarColorMode", function() UpdateBarGrey(); refresh() end), 54) - -- A curve mode brings its own ramp texture and forces a white tint, so these two - -- do nothing while it is selected - dim them (curveGated) rather than leave dead - -- controls live. - local adBarTex = barChild(GUI:CreateTextureDropdown(body, L["Bar Texture"], proxy, "durationBarTexture", refresh), 54) - local adBarCol = barChild(GUI:CreateColorPicker(body, L["Bar Color"], proxy, "durationBarColor", true, refresh, refresh, true), 28) - curveGated[adBarTex] = true; curveGated[adBarCol] = true - barChild(GUI:CreateColorPicker(body, L["Background Color"], proxy, "durationBarBGColor", true, refresh, refresh, true), 28) - barChild(GUI:CreateCheckbox(body, L["Reverse Fill"], proxy, "durationBarReverseFill", refresh), 28) - UpdateBarGrey() - end) - - return by -end -P.AddGroupAppearanceSection = AddGroupAppearanceSection - -S.BuildLayoutGroupsTab = function() - if not S.tabContentFrame then return end - local parent = S.tabContentFrame - local yPos = -10 - local tc = GetThemeColor() - - -- (Removed) a GROW_DIRECTIONS option map — never read. Also note its labels were - -- raw English, so wiring it up as-is would have been a localisation regression. - - -- "+ Create Group" / "+ Filter Group" buttons (prominent, theme-colored). - -- Left = classic member-arranger group; right = registry-linked filter group. - local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab color - local addBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - addBtn:SetHeight(32) - addBtn:SetPoint("TOPLEFT", 8, yPos) - addBtn:SetPoint("TOPRIGHT", parent, "TOP", -2, yPos) - GUI:StyleButton(addBtn, { height = 32, primary = true, accent = gc, text = L["+ Create Group"], font = "DFFontHighlight" }) - addBtn:SetScript("OnClick", function() - local group = CreateLayoutGroup() - if group then - expandedGroups[GroupExpandKey(group.id)] = true - S.SwitchTab("layout") - RefreshPlacedIndicators() - end - end) - - local addFilterBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - addFilterBtn:SetHeight(32) - addFilterBtn:SetPoint("TOPLEFT", parent, "TOP", 2, yPos) - addFilterBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, yPos) - GUI:StyleButton(addFilterBtn, { height = 32, primary = true, accent = gc, text = L["+ Filter Group"], font = "DFFontHighlight" }) - addFilterBtn:SetScript("OnClick", function() - local group = CreateLayoutGroup(nil, "filter") - if group then - expandedGroups[GroupExpandKey(group.id)] = true - S.SwitchTab("layout") - RefreshPlacedIndicators() - end - end) - yPos = yPos - 42 - - -- ── LAYOUT GROUPS heading — mirrors the Effects tab's ACTIVE INDICATORS - -- caption and the Text Designer's group caption so every list tab has one. ── - local groupsHeader = parent:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(groupsHeader, 9, "") - groupsHeader:SetPoint("TOPLEFT", 8, yPos) - groupsHeader:SetText(L["LAYOUT GROUPS"]) - groupsHeader:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - yPos = yPos - 16 - - -- Active tab's groups: the spec-keyed array on My Buffs, the flat - -- spec-independent store on Other Buffs (read-only — visiting the tab - -- never creates adDB.otherLayoutGroups; the add buttons do). - local isOtherGroups = IsOtherTab() - local groups = CurrentLayoutGroups() - - -- Display name lookup (My Buffs: the spec's trackable pool; Other Buffs: - -- ad-hoc/SpellDB resolution — other-pool names aren't in a spec pool) - local spec = ResolveSpec() - local trackable = not isOtherGroups and spec and Adapter and Adapter:GetTrackableAuras(spec) - local displayNames = {} - if trackable then - for _, info in ipairs(trackable) do - displayNames[info.name] = info.display - end - end - local function MemberDisplayName(auraName) - if isOtherGroups then return OtherPoolDisplayName(auraName) end - return displayNames[auraName] or auraName - end - - if #groups == 0 then - -- Empty state - local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) - empty:SetWidth(220) - empty:SetText(L["No layout groups created yet.\nClick '+ Create Group' to get started."]) - empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) - empty:SetJustifyH("CENTER") - else - -- Render group cards (expansion keys are pool-scoped — raw id on My - -- Buffs, "othergroup:" on Other; the id counters overlap) - for _, group in ipairs(groups) do - local expandKey = GroupExpandKey(group.id) - local isExpanded = expandedGroups[expandKey] or false - - -- ── CARD + HEADER ── - local card, header, chevron = CreateCardShell(parent, { - yPos = yPos, - expanded = isExpanded, - borderColor = {r = gc.r * 0.35, g = gc.g * 0.35, b = gc.b * 0.35, a = 0.5}, - chevronColor = gc, - }) - - -- Group name - local nameText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - nameText:SetPoint("LEFT", chevron, "RIGHT", 6, 0) - nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) - nameText:SetMaxLines(1) - local isFilterGroup = (group.kind == "filter") - if isFilterGroup then - local linkCount = 0 - local fsel = group.filterSelection - if fsel then - for _ in pairs(fsel.presets or {}) do linkCount = linkCount + 1 end - for _ in pairs(fsel.customs or {}) do linkCount = linkCount + 1 end - end - local fgInfo = group.name .. " - " .. linkCount .. (linkCount ~= 1 and L[" filters"] or L[" filter"]) - -- Collapsed-state Others Only suffix — mirror the effect-card header - if isOtherGroups and group.othersOnly then - fgInfo = fgInfo .. " - " .. L["Others Only"] - end - nameText:SetText(fgInfo) - else - local memberCount = group.members and #group.members or 0 - nameText:SetText(group.name .. " - " .. memberCount .. (memberCount ~= 1 and L[" indicators"] or L[" indicator"])) - end - nameText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Delete button - local capturedGroupID = group.id - local delBtn = GUI:CreateCloseButton(header, { - size = 22, - onClick = function() - DeleteLayoutGroup(capturedGroupID) - S.SwitchTab("layout") - RefreshPlacedIndicators() - -- Deleting a group deletes its member indicators — same - -- structural refresh as the effect-card delete / eye toggle. - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end, - }) - delBtn:SetPoint("RIGHT", -4, 0) - delBtn:SetFrameLevel(header:GetFrameLevel() + 2) - - -- Eye icon (visibility toggle) — filter groups only; same asset + toggle - -- idiom as the effect-card eye (A3). enabled == false is hidden; nil/true - -- = shown. Toggling is STRUCTURAL: the factory tears down / stands up the - -- group container and the buff-row dedup union changes. - if isFilterGroup then - local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) - eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) - local function shown() return group.enabled ~= false end - -- SetGlyph makes the state colour the new REST colour, so OnLeave - -- restores the state; hover is suppressed while hidden. - local function updateEyeIcon() - if shown() then - eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) - else - eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) - end - eyeBtn:SetGlyphHover(shown()) - end - updateEyeIcon() - eyeBtn:RegisterForClicks("LeftButtonUp") - eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) - eyeBtn:SetScript("OnClick", function() - group.enabled = (group.enabled == false) and true or false - updateEyeIcon() - S.SwitchTab("layout") - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end) - if not shown() then - nameText:SetAlpha(0.5) - end - end - - -- Header click → toggle expansion - header:SetScript("OnClick", function() - expandedGroups[expandKey] = not expandedGroups[expandKey] - S.SwitchTab("layout") - end) - header:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - header:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - local totalCardH = 30 - - -- ── BODY (when expanded) ── - if isExpanded then - local body = CreateFrame("Frame", nil, card, "BackdropTemplate") - body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) - body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) - ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, - {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) - - local by = -10 - local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 - if bodyWidth < 100 then bodyWidth = 240 end - - -- Group Name (editable) - local nameLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(nameLabel, 8, "") - nameLabel:SetPoint("TOPLEFT", 8, by) - nameLabel:SetText(L["GROUP NAME"]) - nameLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 16 - - local nameEdit = CreateFrame("EditBox", nil, body, "BackdropTemplate") - nameEdit:SetHeight(22) - nameEdit:SetPoint("TOPLEFT", 8, by) - nameEdit:SetPoint("RIGHT", body, "RIGHT", -8, 0) - nameEdit:SetAutoFocus(false) - nameEdit:SetText(group.name) - nameEdit:SetMaxLetters(30) - GUI:StyleEditBox(nameEdit, {}) - nameEdit:SetScript("OnEnterPressed", function(self) - local val = self:GetText() - if val and val ~= "" then - group.name = val - end - self:ClearFocus() - S.SwitchTab("layout") - end) - nameEdit:SetScript("OnEscapePressed", function(self) - self:SetText(group.name) - self:ClearFocus() - end) - by = by - 32 - - if isFilterGroup then - -- ── LINKED FILTERS SECTION (filter groups) ── - -- One collapsed chip per linked registry filter: localized preset name - -- (or custom filter name) + live spell count, remove ✕. Links are stable - -- REFERENCES (preset keys / custom ids) — never copies — so filter edits - -- propagate live. Link/unlink is structural (container rebuild + the - -- buff-row dedup union moves), so run the full refresh path. - local R = DF.FilterRegistry - local fsel = group.filterSelection - if not fsel then fsel = {}; group.filterSelection = fsel end - fsel.presets = fsel.presets or {} - fsel.customs = fsel.customs or {} - - local function StructuralFilterRefresh() - S.SwitchTab("layout") - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end - - local lfLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(lfLabel, 8, "") - lfLabel:SetPoint("TOPLEFT", 8, by) - lfLabel:SetText(L["LINKED FILTERS"]) - lfLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - -- Custom-filter spell count (curated spells + raw IDs) - local function CustomFilterCount(cf) - local n = 0 - if cf then - for _ in pairs(cf.spells or {}) do n = n + 1 end - for _ in pairs(cf.rawIDs or {}) do n = n + 1 end - end - return n - end - - -- Linked list: presets in R.Categories order, then customs name-sorted. - local linked = {} - for _, cat in ipairs(R.Categories) do - if fsel.presets[cat.key] then - local enabled, total = R:PresetCounts(cat.key) - tinsert(linked, { kind = "preset", key = cat.key, - label = format("%s |cff888888(%d/%d)|r", L[cat.name], enabled, total) }) - end - end - local linkedCustoms = {} - for cfId in pairs(fsel.customs) do tinsert(linkedCustoms, cfId) end - sort(linkedCustoms, function(a, b) - local fa, fb = R:GetCustomFilter(a), R:GetCustomFilter(b) - local na, nb = (fa and fa.name or ""), (fb and fb.name or "") - if na ~= nb then return na < nb end - return a < b - end) - for _, cfId in ipairs(linkedCustoms) do - local cf = R:GetCustomFilter(cfId) - tinsert(linked, { kind = "custom", key = cfId, - label = format("%s |cff888888(%d)|r", (cf and cf.name) or cfId, CustomFilterCount(cf)) }) - end - - if #linked > 0 then - for _, link in ipairs(linked) do - local chipRow = CreateFrame("Frame", nil, body, "BackdropTemplate") - chipRow:SetHeight(24) - chipRow:SetPoint("TOPLEFT", 8, by) - chipRow:SetPoint("RIGHT", body, "RIGHT", -8, 0) - ApplyBackdrop(chipRow, - {r = 0.11, g = 0.11, b = 0.11, a = 1}, - {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3}) - - -- Remove ✕ (mirror the member-row remove idiom) - local remBtn = DF.GUI:CreateGlyphButton(chipRow, { - size = 18, iconSize = 12, - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", - color = { 0.55, 0.30, 0.30 }, - hoverColor = { 1, 0.40, 0.40 }, - }) - remBtn:SetPoint("RIGHT", -4, 0) - local capturedLink = link - remBtn:SetScript("OnClick", function() - if capturedLink.kind == "preset" then - fsel.presets[capturedLink.key] = nil - else - fsel.customs[capturedLink.key] = nil - end - StructuralFilterRefresh() - end) - - local chipText = chipRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - chipText:SetPoint("LEFT", 8, 0) - chipText:SetPoint("RIGHT", remBtn, "LEFT", -4, 0) - chipText:SetMaxLines(1) - chipText:SetJustifyH("LEFT") - chipText:SetText(link.label) - chipText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - by = by - 28 - end - else - local noLinks = body:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - noLinks:SetPoint("TOPLEFT", 12, by) - noLinks:SetText(L["No filters linked yet"]) - noLinks:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - by = by - 20 - end - - -- "+ Add Filter" button → mini-picker of unlinked presets + customs - by = by - 6 - local addFilterLinkBtn = CreateFrame("Button", nil, body, "BackdropTemplate") - addFilterLinkBtn:SetHeight(22) - addFilterLinkBtn:SetPoint("TOPLEFT", 8, by) - addFilterLinkBtn:SetPoint("RIGHT", body, "RIGHT", -8, 0) - GUI:StyleButton(addFilterLinkBtn, { height = 22, primary = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 11 }, text = L["Add Filter"] }) - GUI:SetSettingsFont(addFilterLinkBtn.Text, 9, "") - addFilterLinkBtn:SetScript("OnClick", function() - -- Build the unlinked candidate list: presets (Categories order, - -- live counts), then customs (name-sorted). No Uncategorised - -- option by design — a group must resolve to an include map. - local candidates = {} - for _, cat in ipairs(R.Categories) do - if not fsel.presets[cat.key] then - local enabled, total = R:PresetCounts(cat.key) - tinsert(candidates, { kind = "preset", key = cat.key, - label = format("%s |cff888888(%d/%d)|r", L[cat.name], enabled, total) }) - end - end - local freeCustoms = {} - for cfId in pairs(R:GetStore().customFilters) do - if not fsel.customs[cfId] then tinsert(freeCustoms, cfId) end - end - sort(freeCustoms, function(a, b) - local fa, fb = R:GetCustomFilter(a), R:GetCustomFilter(b) - local na, nb = (fa and fa.name or ""), (fb and fb.name or "") - if na ~= nb then return na < nb end - return a < b - end) - for _, cfId in ipairs(freeCustoms) do - local cf = R:GetCustomFilter(cfId) - tinsert(candidates, { kind = "custom", key = cfId, - label = format("%s |c%s(%s)|r", (cf and cf.name) or cfId, GUI:ToneHex("info"), L["Custom"]) }) - end - - -- Create/reuse the picker dropdown (same overlay/ESC/scroll idiom - -- as the member picker above) - local dropName = "DFADFilterGroupPicker" - local drop = _G[dropName] - if not drop then - drop = CreateFrame("Frame", dropName, UIParent, "BackdropTemplate") - drop:SetFrameStrata("FULLSCREEN_DIALOG") - drop:SetClampedToScreen(true) - local overlay = CreateFrame("Button", nil, UIParent) - overlay:SetAllPoints(UIParent) - overlay:SetFrameStrata("FULLSCREEN") - overlay:Hide() - overlay:SetScript("OnClick", function() - drop:Hide() - overlay:Hide() - end) - drop._overlay = overlay - -- SetPropagateKeyboardInput is protected in combat for - -- insecure code: skip the calls there (keys propagate - -- anyway — ESC just hides the dropdown), and don't trap - -- keyboard input if built mid-combat. - if not InCombatLockdown() then - drop:EnableKeyboard(true) - drop:SetPropagateKeyboardInput(true) - end - drop:SetScript("OnKeyDown", function(self, key) - if key == "ESCAPE" then - if not InCombatLockdown() then self:SetPropagateKeyboardInput(false) end - self:Hide() - else - if not InCombatLockdown() then self:SetPropagateKeyboardInput(true) end - end - end) - drop:SetScript("OnHide", function(self) - self._ownerBtn = nil - if self._overlay then self._overlay:Hide() end - end) - end - if drop:IsShown() and drop._ownerBtn == addFilterLinkBtn then - drop:Hide() - return - end - drop._ownerBtn = addFilterLinkBtn - - local DROP_W = 240 - local MAX_H = 300 - drop:SetWidth(DROP_W) - ApplyBackdrop(drop, C_BACKGROUND, C_BORDER) - - if not drop._scrollFrame then - local sf = CreateFrame("ScrollFrame", nil, drop) - sf:SetPoint("TOPLEFT", 0, 0) - sf:SetPoint("BOTTOMRIGHT", 0, 0) - drop._scrollFrame = sf - local sc = CreateFrame("Frame", nil, sf) - sc:SetWidth(DROP_W) - sf:SetScrollChild(sc) - drop._scrollChild = sc - sf:SetScript("OnMouseWheel", function(self2, delta2) - local cur = self2:GetVerticalScroll() - local maxS = max(0, self2:GetVerticalScrollRange()) - self2:SetVerticalScroll(max(0, min(maxS, cur - (delta2 * 24)))) - end) - end - local scrollChild = drop._scrollChild - local scrollFrame = drop._scrollFrame - scrollChild:SetWidth(DROP_W) - for _, child in ipairs({scrollChild:GetChildren()}) do child:Hide(); child:SetParent(nil) end - for _, rgn in ipairs({scrollChild:GetRegions()}) do - if rgn:GetObjectType() == "FontString" or rgn:GetObjectType() == "Texture" then rgn:Hide() end - end - scrollFrame:Show() - scrollChild:EnableMouseWheel(true) - scrollChild:SetScript("OnMouseWheel", function(_, delta2) - scrollFrame:GetScript("OnMouseWheel")(scrollFrame, delta2) - end) - - local dy2 = -4 - if #candidates == 0 then - local none = scrollChild:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(none, 9, "") - none:SetPoint("TOPLEFT", 8, dy2 - 4) - none:SetText(L["No filters available"]) - none:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) - dy2 = dy2 - 24 - end - for _, cand in ipairs(candidates) do - local ROW_H = 24 - local row = CreateFrame("Button", nil, scrollChild) - row:SetHeight(ROW_H) - row:SetPoint("TOPLEFT", 4, dy2) - row:SetPoint("RIGHT", scrollChild, "RIGHT", -4, 0) - - local rName = row:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(rName, 9, "") - rName:SetPoint("LEFT", 8, 0) - rName:SetText(cand.label) - rName:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local hl = row:CreateTexture(nil, "BACKGROUND") - hl:SetAllPoints() - hl:SetColorTexture(1, 1, 1, 0) - row:SetScript("OnEnter", function() hl:SetColorTexture(1, 1, 1, 0.03) end) - row:SetScript("OnLeave", function() hl:SetColorTexture(1, 1, 1, 0) end) - - local capturedCand = cand - row:SetScript("OnClick", function() - if capturedCand.kind == "preset" then - fsel.presets[capturedCand.key] = true - else - fsel.customs[capturedCand.key] = true - end - drop:Hide() - StructuralFilterRefresh() - end) - dy2 = dy2 - ROW_H - end - local totalH = -dy2 + 4 - scrollChild:SetHeight(totalH) - drop:SetHeight(math.min(totalH, MAX_H)) - - drop:ClearAllPoints() - drop:SetPoint("TOPLEFT", addFilterLinkBtn, "BOTTOMLEFT", 0, -2) - drop:Show() - if drop._overlay then drop._overlay:Show() end - end) - by = by - 26 - - -- "Create Filter" → jump to the Filter Designer and pulse its - -- New Filter button, for users who arrive here without a custom - -- filter to link yet. - local createFilterBtn = CreateFrame("Button", nil, body, "BackdropTemplate") - createFilterBtn:SetHeight(22) - createFilterBtn:SetPoint("TOPLEFT", 8, by) - createFilterBtn:SetPoint("RIGHT", body, "RIGHT", -8, 0) - GUI:StyleButton(createFilterBtn, { height = 22, text = L["Create Filter"] }) - GUI:SetSettingsFont(createFilterBtn.Text, 9, "") - createFilterBtn:SetScript("OnClick", function() - if GUI.SelectTab and GUI.Pages and GUI.Pages["auras_filterdesigner"] then - GUI.SelectTab("auras_filterdesigner") - -- Page content builds on first show (inside SelectTab), so - -- the button reference exists by now. - -- The add action is a row inside the Filter Designer's - -- scrolling left list, so the S.page scrolls it into view and - -- pulses it itself rather than handing back a bare widget. - local fdPage = GUI.Pages["auras_filterdesigner"] - if fdPage and fdPage._fdFocusNewFilter then - fdPage._fdFocusNewFilter() - end - end - end) - by = by - 28 - - else - -- ── MEMBERS SECTION ── - local memLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(memLabel, 8, "") - memLabel:SetPoint("TOPLEFT", 8, by) - memLabel:SetText(L["MEMBERS"]) - memLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - if group.members and #group.members > 0 then - for mi, member in ipairs(group.members) do - local memberRow = CreateFrame("Frame", nil, body, "BackdropTemplate") - memberRow:SetHeight(34) - memberRow:SetPoint("TOPLEFT", 8, by) - memberRow:SetPoint("RIGHT", body, "RIGHT", -8, 0) - ApplyBackdrop(memberRow, - {r = 0.11, g = 0.11, b = 0.11, a = 1}, - {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3}) - - -- Up/Down buttons for reordering (stacked vertically on left) - local canMoveUp = mi > 1 - local canMoveDown = mi < #group.members - local capturedMi = mi - - if canMoveUp then - -- One arrow texture serves both directions via rotation. - local upBtn = DF.GUI:CreateGlyphButton(memberRow, { - width = 20, height = 16, iconSize = 14, - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more", - rotation = math.rad(180), - }) - upBtn:SetPoint("TOPLEFT", 2, -1) - upBtn:SetScript("OnClick", function() - SwapGroupMembers(capturedGroupID, capturedMi, capturedMi - 1) - S.SwitchTab("layout") - RefreshPlacedIndicators() - -- Positions moved (member index feeds the grid) — re-arrange live frames. - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - end - if canMoveDown then - local downBtn = DF.GUI:CreateGlyphButton(memberRow, { - width = 20, height = 16, iconSize = 14, - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more", - }) - downBtn:SetPoint("BOTTOMLEFT", 2, 1) - downBtn:SetScript("OnClick", function() - SwapGroupMembers(capturedGroupID, capturedMi, capturedMi + 1) - S.SwitchTab("layout") - RefreshPlacedIndicators() - -- Positions moved (member index feeds the grid) — re-arrange live frames. - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - end - - -- Spell icon (Other: nil spec — GetAuraIcon degrades to - -- ad-hoc "#" / SpellDB-by-name resolution) - local memberSpec = not isOtherGroups and ResolveSpec() or nil - local memberIconTex = GetAuraIcon(memberSpec, member.auraName) - local mSpellIcon = memberRow:CreateTexture(nil, "ARTWORK") - mSpellIcon:SetSize(22, 22) - mSpellIcon:SetPoint("LEFT", 26, 0) - if memberIconTex then - mSpellIcon:SetTexture(memberIconTex) - mSpellIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) - else - -- Color swatch fallback - local auraInfo2 = nil - local trackable2 = memberSpec and Adapter and Adapter:GetTrackableAuras(memberSpec) - if trackable2 then - for _, ai in ipairs(trackable2) do - if ai.name == member.auraName then auraInfo2 = ai; break end - end - end - if auraInfo2 then - mSpellIcon:SetColorTexture(auraInfo2.color[1] * 0.5, auraInfo2.color[2] * 0.5, auraInfo2.color[3] * 0.5, 1) - else - mSpellIcon:SetColorTexture(0.25, 0.25, 0.25, 1) - end - end - - -- Type badge (members live in the group's pool = the - -- active tab's pool) - local memberType = nil - local memberAuraCfg = CurrentAuraPool()[member.auraName] - if memberAuraCfg and memberAuraCfg.indicators then - for _, ind in ipairs(memberAuraCfg.indicators) do - if ind.id == member.indicatorID then - memberType = ind.type - break - end - end - end - local mBadgeColor = BADGE_COLORS[memberType or "icon"] or BADGE_COLORS.icon - local mBadgeLabel = S.PLACED_TYPE_LABELS[memberType or "icon"] or "Icon" - - local mBadge = CreateFrame("Frame", nil, memberRow, "BackdropTemplate") - mBadge:SetHeight(16) - mBadge:SetPoint("LEFT", mSpellIcon, "RIGHT", 4, 0) - ApplyBackdrop(mBadge, - {r = mBadgeColor.r * 0.20, g = mBadgeColor.g * 0.20, b = mBadgeColor.b * 0.20, a = 1}, - {r = mBadgeColor.r * 0.45, g = mBadgeColor.g * 0.45, b = mBadgeColor.b * 0.45, a = 0.6}) - local mBadgeText = mBadge:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(mBadgeText, 8, "OUTLINE") - mBadgeText:SetPoint("CENTER", 0, 0) - mBadgeText:SetText(mBadgeLabel) - mBadgeText:SetTextColor(1, 1, 1) - mBadge:SetWidth(max(mBadgeText:GetStringWidth() + 12, 32)) - - -- Remove button (using close icon) - -- Red at rest, brighter red on hover: an inline destructive remove. - local remBtn = DF.GUI:CreateGlyphButton(memberRow, { - size = 18, iconSize = 12, - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", - color = { 0.55, 0.30, 0.30 }, - hoverColor = { 1, 0.40, 0.40 }, - }) - remBtn:SetPoint("RIGHT", -4, 0) - local capturedMember = member - remBtn:SetScript("OnClick", function() - RemoveGroupMember(capturedGroupID, capturedMember.auraName, capturedMember.indicatorID) - -- Also delete the placed indicator itself - RemoveIndicatorInstance(capturedMember.auraName, capturedMember.indicatorID) - S.SwitchTab("layout") - RefreshPlacedIndicators() - RefreshPreviewEffects() - -- Structural change: same full refresh as the - -- effect-card ✕ / group delete (indicator removed). - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end) - - -- Customise button (navigates to Effects tab for this indicator) - -- Accent-tinted action button: persistent accent fill + - -- accent border + accent label at rest, accent-wash hover. - local custBtn = CreateFrame("Button", nil, memberRow, "BackdropTemplate") - custBtn:SetPoint("RIGHT", remBtn, "LEFT", -4, 0) - GUI:StyleButton(custBtn, { - width = 56, height = 18, - text = L["Customise"], - tinted = true, - accent = GetThemeColor(), - }) - local capturedAuraName = member.auraName - local capturedIndID = member.indicatorID - custBtn:SetScript("OnClick", function() - -- Card keys embed the B1 pool prefix in the name - -- segment ("placed:other:#" on Other) - local cardKey = "placed:" .. PoolKeyPrefix() .. capturedAuraName .. "#" .. capturedIndID - wipe(expandedCards) - expandedCards[cardKey] = true - S.activeTab = "effects" - DF:AuraDesigner_RefreshPage() - end) - - -- Aura name - local mName = memberRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - mName:SetPoint("LEFT", mBadge, "RIGHT", 6, 0) - mName:SetPoint("RIGHT", custBtn, "LEFT", -4, 0) - mName:SetMaxLines(1) - mName:SetText(MemberDisplayName(member.auraName)) - mName:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - by = by - 38 - end - else - local noMem = body:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - noMem:SetPoint("TOPLEFT", 12, by) - noMem:SetText(L["No members yet"]) - noMem:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - by = by - 20 - end - - -- "+ Add aura" button - by = by - 6 - local addMemBtn = CreateFrame("Button", nil, body, "BackdropTemplate") - addMemBtn:SetHeight(22) - addMemBtn:SetPoint("TOPLEFT", 8, by) - addMemBtn:SetPoint("RIGHT", body, "RIGHT", -8, 0) - GUI:StyleButton(addMemBtn, { height = 22, primary = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 11 }, text = L["Add aura"] }) - GUI:SetSettingsFont(addMemBtn.Text, 9, "") - addMemBtn:SetScript("OnClick", function() - -- Shared spell picker, group context: searchable, - -- class/category-filterable rows + add-by-ID, each row - -- carrying Icon/Square buttons that create the indicator - -- and enrol it in this group in one click. - OpenGroupSpellPicker(capturedGroupID) - end) - by = by - 28 - end -- isFilterGroup / members branch - - -- ── PLACEMENT SECTION ── - by = by - 10 - local placeLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(placeLabel, 8, "") - placeLabel:SetPoint("TOPLEFT", 8, by) - placeLabel:SetText(L["PLACEMENT"]) - placeLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - -- Use GUI widgets with the group table as the proxy - local anchorDrop = GUI:CreateDropdown(body, L["Anchor"], OPTS.ANCHOR_OPTIONS, group, "anchor", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - anchorDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if anchorDrop.SetWidth then anchorDrop:SetWidth(bodyWidth - 10) end - by = by - 54 - - local oxSlider = GUI:CreateSlider(body, L["Offset X"], -150, 150, 1, group, "offsetX", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end, function() - RefreshPlacedIndicators() - end) - oxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if oxSlider.SetWidth then oxSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - local oySlider = GUI:CreateSlider(body, L["Offset Y"], -150, 150, 1, group, "offsetY", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end, function() - RefreshPlacedIndicators() - end) - oySlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if oySlider.SetWidth then oySlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- ── GROWTH SECTION ── - by = by - 10 - local growLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(growLabel, 8, "") - growLabel:SetPoint("TOPLEFT", 8, by) - growLabel:SetText(L["GROWTH"]) - growLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - -- Auto-migrate legacy single-direction values to new format - local gd = group.growDirection or "RIGHT" - if not gd:find("_") then - local LEGACY_MAP = { RIGHT = "RIGHT_DOWN", LEFT = "LEFT_DOWN", UP = "UP_RIGHT", DOWN = "DOWN_RIGHT" } - group.growDirection = LEGACY_MAP[gd] or "RIGHT_DOWN" - end - - local growthControl = GUI:CreateGrowthControl(body, group, "growDirection", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end) - growthControl:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if growthControl.SetWidth then growthControl:SetWidth(bodyWidth - 10) end - by = by - 158 - - local iprSlider = GUI:CreateSlider(body, L["Icons Per Row"], 1, 20, 1, group, "iconsPerRow", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end, function() - RefreshPlacedIndicators() - end) - iprSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if iprSlider.SetWidth then iprSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - local spacingSlider = GUI:CreateSlider(body, L["Spacing"], -5, 20, 1, group, "spacing", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end, function() - RefreshPlacedIndicators() - end) - spacingSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if spacingSlider.SetWidth then spacingSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - if isFilterGroup then - -- Uniform per-group styling: one icon size + slot cap for every - -- spell the linked filters match (no per-spell styling by design). - local sizeSlider = GUI:CreateSlider(body, L["Icon Size"], 8, 64, 1, group, "iconSize", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end, function() - RefreshPlacedIndicators() - end) - sizeSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if sizeSlider.SetWidth then sizeSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- Max Icons is STRUCTURAL (slot count is declared at container - -- build) — the factory Rebuild path handles it (OOC-deferred). - local maxSlider = GUI:CreateSlider(body, L["Max Icons"], 1, 20, 1, group, "maxIcons", function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end, function() - RefreshPlacedIndicators() - end) - maxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if maxSlider.SetWidth then maxSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- ── SORT (Wave 2) ── per-group sort is TUNING: the factory - -- re-applies it in place via ApplyTuning (OOC-immediate; - -- self-defers in combat) — no rebuild. The fields are - -- OPTIONAL on the group (othersOnly idiom): absent = the - -- family default "DEFAULT" (Blizzard slot order, the - -- pre-Wave-2 behaviour), read through a customGet so legacy - -- groups display correctly without a write-on-open. - local sortRefresh = function() - RefreshPlacedIndicators() - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - local sortMineCb -- forward capture: the dropdown greys it - local sortDrop = GUI:CreateDropdown(body, L["Sort Order"], OPTS.SORT_OPTIONS, nil, nil, function() - sortRefresh() - if sortMineCb then sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "DEFAULT")) end - end, function() return group.sortOrder or "DEFAULT" end, - function(v) group.sortOrder = v end) - sortDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, -(-by)) - if sortDrop.SetWidth then sortDrop:SetWidth(bodyWidth - 10) end - by = by - 54 - - sortMineCb = GUI:CreateCheckbox(body, L["My Auras First"], group, "sortMineFirst", sortRefresh) - sortMineCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(-by)) - sortMineCb:SetWidth(bodyWidth - 16) - sortMineCb.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] - sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "DEFAULT")) - by = by - 26 - - local sortRevCb = GUI:CreateCheckbox(body, L["Reverse Order"], group, "sortReverse", sortRefresh) - sortRevCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(-by)) - sortRevCb:SetWidth(bodyWidth - 16) - sortRevCb.tooltip = L["Reverse the sort direction."] - by = by - 30 - - -- ── OTHERS ONLY (Other Buffs tab only — flat-store groups) ── - -- Same idiom as the effect-card checkbox: the group's filter - -- string ("HELPFUL|!PLAYER") binds at container build, so - -- toggling is STRUCTURAL (folded into the fgroup struct sig → - -- the factory Rebuilds), and the buff-row dedup union moves - -- (an othersOnly group's spells keep their row icon). - if isOtherGroups then - local ooCb = GUI:CreateCheckbox(body, L["Others Only"], group, "othersOnly", function() - S.SwitchTab("layout") - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end) - ooCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(-by)) - ooCb:SetWidth(bodyWidth - 16) - ooCb.tooltip = L["Only show other players' casts of these buffs."] - by = by - 34 - end - - -- ── APPEARANCE (collapsible — the effect-card section idiom) ── - by = by - 10 - by = AddGroupAppearanceSection(body, group, bodyWidth, by, expandKey) - end - - local bodyH = -by + 12 - body:SetHeight(bodyH) - totalCardH = totalCardH + bodyH - end - - card:SetHeight(totalCardH) - yPos = yPos - totalCardH - 5 - end - end - - parent:SetHeight(max(-yPos + 20, 200)) -end - --- ============================================================ --- DEBUFFS TAB — DEBUFF CATEGORY GROUPS (C2) --- Each card owns a SELECTION of the native 12.1 debuff categories (checkbox --- picker reusing the Aura Filters debuff column's controls) plus the shared --- filter-group layout controls. Groups are spec-independent --- (adDB.debuffGroups; lazily created on the first add — visiting the tab --- writes nothing). Categories claimed by an enabled group are dropped from --- the main debuff bar (C1 row-claim dedup), so EVERY selection / eye / --- delete / add edit runs the FULL structural chain: InvalidateAuraLayout --- bumps the version the row's claim fold AND the factory's dgroup record --- cache re-resolve on; UpdateAllFrames + ForceRefreshAllFrames re-drive the --- live frames and AD containers. Card keys are "dgroup:" in --- expandedGroups — layout-group cards key by raw numeric id, so the string --- prefix can never collide with them (the two id counters overlap). --- ============================================================ - -S.BuildDebuffGroupsTab = function() - if not S.tabContentFrame then return end - local parent = S.tabContentFrame - local yPos = -10 - local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab accent - - -- Category picker rows: the Aura Filters debuff column's exact L keys + - -- tooltips, mapped onto group.selection's keys. Built per tab build so a - -- runtime locale overlay is picked up (file-scope L tables freeze enUS). - local CATEGORY_DEFS = { - { key = "boss", label = L["Boss Debuffs"], tooltip = L["Debuffs applied by dungeon and raid bosses."] }, - { key = "role", label = L["Role Debuffs"], tooltip = L["Debuffs Blizzard flags as important for your role."] }, - { key = "priority", label = L["Priority Debuffs"], tooltip = L["Debuffs Blizzard flags as high priority."] }, - { key = "crowdControl", label = L["Crowd Control"], tooltip = L["CC effects like stuns, roots, and incapacitates."] }, - { key = "raid", label = L["Raid Debuffs"], tooltip = L["Other debuffs Blizzard flags for raid frames."] }, - { key = "dispellable", label = L["Dispellable Debuffs"], tooltip = L["Debuffs that can be dispelled. Use the dropdown below to choose which dispels count."] }, - } - - -- FULL structural refresh incl. tab rebuild — discrete edits (category - -- checkboxes, dispel mode, hide-long controls, eye, delete, add): the - -- claims union moves AND the card summary / grey states must rebuild. - local function StructuralDebuffGroupRefresh() - S.SwitchTab("layout") - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end - - -- Same structural chain WITHOUT the tab rebuild — slider/dropdown-safe - -- (a S.SwitchTab from inside a slider callback destroys the widget - -- mid-interaction). Layout edits don't move the claims union, but the - -- version bump keeps the factory's version-keyed dgroup record cache - -- honest and costs one sig-gated re-resolve (offsets → ApplyStyle, - -- maxIcons → Rebuild). - local function LayoutDebuffGroupRefresh() - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end - - -- "+ Debuff Group" (prominent, tab accent — mirrors "+ Create Group"). - -- The debuffGroups array is born lazily on this first add. - local addBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - addBtn:SetHeight(32) - addBtn:SetPoint("TOPLEFT", 8, yPos) - addBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -8, yPos) - GUI:StyleButton(addBtn, { height = 32, primary = true, accent = gc, text = L["+ Debuff Group"], font = "DFFontHighlight" }) - addBtn:SetScript("OnClick", function() - local group = CreateDebuffGroup() - if group then - expandedGroups["dgroup:" .. group.id] = true - -- A new group claims Boss + Role by default, so the main debuff - -- bar drops them immediately: full structural chain, not just a - -- tab rebuild. - StructuralDebuffGroupRefresh() - end - end) - yPos = yPos - 42 - - -- Dedup explainer (§11.1 mock): how the row-claim handoff behaves. - local dedupHint = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - dedupHint:SetPoint("TOPLEFT", 8, yPos) - dedupHint:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - dedupHint:SetJustifyH("LEFT") - dedupHint:SetWordWrap(true) - dedupHint:SetText(L["Categories shown here are hidden from the main debuff bar automatically."]) - dedupHint:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - yPos = yPos - 30 - - -- ── DEBUFF GROUPS heading — mirrors the Layout Groups tab's caption. ── - local groupsHeader = parent:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(groupsHeader, 9, "") - groupsHeader:SetPoint("TOPLEFT", 8, yPos) - groupsHeader:SetText(L["DEBUFF GROUPS"]) - groupsHeader:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - yPos = yPos - 16 - - -- READ path: visiting the tab never creates adDB.debuffGroups. - local groups = DebuffGroupsRead() - - if #groups == 0 then - -- Empty state - local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) - empty:SetWidth(220) - empty:SetText(L["No debuff groups created yet.\nClick '+ Debuff Group' to get started."]) - empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.7) - empty:SetJustifyH("CENTER") - else - for _, group in ipairs(groups) do - local cardKey = "dgroup:" .. group.id - local isExpanded = expandedGroups[cardKey] or false - local capturedGroupID = group.id - - -- ── CARD + HEADER ── - local card, header, chevron = CreateCardShell(parent, { - yPos = yPos, - expanded = isExpanded, - borderColor = {r = gc.r * 0.35, g = gc.g * 0.35, b = gc.b * 0.35, a = 0.5}, - chevronColor = gc, - }) - - -- Group name + collapsed summary: the selected category names - -- (the A5 collapsed treatment, categories instead of link count). - local nameText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - nameText:SetPoint("LEFT", chevron, "RIGHT", 6, 0) - nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) - nameText:SetMaxLines(1) - local selectedNames = {} - local selRead = group.selection - if selRead then - for _, def in ipairs(CATEGORY_DEFS) do - if selRead[def.key] then tinsert(selectedNames, def.label) end - end - end - local summary = (#selectedNames > 0) and table.concat(selectedNames, ", ") - or L["No categories selected"] - nameText:SetText(group.name .. " - " .. summary) - nameText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Delete button — full structural chain: the group's claimed - -- categories return to the main debuff bar. - local delBtn = GUI:CreateCloseButton(header, { - size = 22, - onClick = function() - DeleteDebuffGroup(capturedGroupID) - StructuralDebuffGroupRefresh() - end, - }) - delBtn:SetPoint("RIGHT", -4, 0) - delBtn:SetFrameLevel(header:GetFrameLevel() + 2) - - -- Eye icon (visibility toggle) — same asset + idiom as the filter - -- group eye (A3/A5). Toggling is STRUCTURAL: the factory tears - -- down / stands up the container and the claims union moves. - local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) - eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) - local function shown() return group.enabled ~= false end - -- SetGlyph makes the state colour the new REST colour, so OnLeave - -- restores the state; hover is suppressed while hidden. - local function updateEyeIcon() - if shown() then - eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) - else - eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) - end - eyeBtn:SetGlyphHover(shown()) - end - updateEyeIcon() - eyeBtn:RegisterForClicks("LeftButtonUp") - eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) - eyeBtn:SetScript("OnClick", function() - group.enabled = (group.enabled == false) and true or false - updateEyeIcon() - StructuralDebuffGroupRefresh() - end) - if not shown() then - nameText:SetAlpha(0.5) - end - - -- Header click → toggle expansion - header:SetScript("OnClick", function() - expandedGroups[cardKey] = not expandedGroups[cardKey] - S.SwitchTab("layout") - end) - header:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - header:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - local totalCardH = 30 - - -- ── BODY (when expanded) ── - if isExpanded then - local body = CreateFrame("Frame", nil, card, "BackdropTemplate") - body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) - body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) - ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, - {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) - - local by = -10 - local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 - if bodyWidth < 100 then bodyWidth = 240 end - - -- Repair a missing selection table (hand-edited data). All - -- categories start FALSE so an unconfigured group renders — - -- and claims — nothing until the user picks; modifier - -- defaults mirror CreateDebuffGroup. - local sel = group.selection - if not sel then - sel = { dispellableMode = "PLAYER", hideLongMinutes = 5, keepImportant = true } - group.selection = sel - end - - -- Group Name (editable) - local nameLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(nameLabel, 8, "") - nameLabel:SetPoint("TOPLEFT", 8, by) - nameLabel:SetText(L["GROUP NAME"]) - nameLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 16 - - local nameEdit = CreateFrame("EditBox", nil, body, "BackdropTemplate") - nameEdit:SetHeight(22) - nameEdit:SetPoint("TOPLEFT", 8, by) - nameEdit:SetPoint("RIGHT", body, "RIGHT", -8, 0) - nameEdit:SetAutoFocus(false) - nameEdit:SetText(group.name) - nameEdit:SetMaxLetters(30) - GUI:StyleEditBox(nameEdit, {}) - nameEdit:SetScript("OnEnterPressed", function(self) - local val = self:GetText() - if val and val ~= "" then - group.name = val - end - self:ClearFocus() - -- Name feeds no sig — cosmetic only (header + canvas label). - S.SwitchTab("layout") - RefreshPlacedIndicators() - end) - nameEdit:SetScript("OnEscapePressed", function(self) - self:SetText(group.name) - self:ClearFocus() - end) - by = by - 32 - - -- ── CATEGORIES SECTION ── - -- Checkbox list writing group.selection.* — every toggle is - -- structural (records move AND the claims union moves). - local catLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(catLabel, 8, "") - catLabel:SetPoint("TOPLEFT", 8, by) - catLabel:SetText(L["CATEGORIES"]) - catLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - for _, def in ipairs(CATEGORY_DEFS) do - local cb = GUI:CreateCheckbox(body, def.label, sel, def.key, StructuralDebuffGroupRefresh) - cb:SetPoint("TOPLEFT", 8, by) - cb.tooltip = def.tooltip - by = by - 26 - - if def.key == "dispellable" then - -- Mode dropdown, indented under Dispellable Debuffs - -- (the Aura Filters column's control, group-scoped; - -- was a two-state toggle until the PTR-5 DISPELLABLE - -- token added the third "Any Dispel Type" mode). - local modeDrop = GUI:CreateDropdown(body, L["Mode"], { - PLAYER = L["Dispellable By Me"], - ALL = L["All Dispellable"], - ANY = L["Any Dispel Type"], - _order = { "PLAYER", "ALL", "ANY" }, - }, sel, "dispellableMode", StructuralDebuffGroupRefresh) - modeDrop:SetPoint("TOPLEFT", 24, by) - if modeDrop.SetWidth then modeDrop:SetWidth(bodyWidth - 30) end - modeDrop.tooltip = L["Dispellable By Me: only debuffs you can dispel. All Dispellable: any debuff that can be dispelled. Any Dispel Type: every debuff with a dispel type, even ones that cannot be dispelled."] - modeDrop:SetEnabled(not not sel.dispellable) - by = by - 54 - end - end - - by = by - 4 - local hideLongCb = GUI:CreateCheckbox(body, L["Hide Long Debuffs"], sel, "hideLong", StructuralDebuffGroupRefresh) - hideLongCb:SetPoint("TOPLEFT", 8, by) - hideLongCb.tooltip = L["Hide debuffs whose total duration is longer than the threshold. Debuffs with no duration (permanent auras) are also hidden while this is on."] - by = by - 26 - - local minSlider = GUI:CreateSlider(body, L["Hide Longer Than (minutes)"], 1, 30, 1, - sel, "hideLongMinutes", LayoutDebuffGroupRefresh) - minSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 8, by) - if minSlider.SetWidth then minSlider:SetWidth(bodyWidth - 10) end - minSlider:SetEnabled(not not sel.hideLong) - by = by - 54 - - -- Keep-important, indented under Hide Long (Aura Filters idiom) - local keepCb = GUI:CreateCheckbox(body, L["Keep important debuffs"], sel, "keepImportant", StructuralDebuffGroupRefresh) - keepCb:SetPoint("TOPLEFT", 24, by) - keepCb.tooltip = L["Boss, Role, and Priority debuffs stay visible even when their duration is over the threshold."] - keepCb:SetEnabled(not not sel.hideLong) - by = by - 30 - - -- ── PLACEMENT SECTION ── (the shared filter-group layout controls) - by = by - 10 - local placeLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(placeLabel, 8, "") - placeLabel:SetPoint("TOPLEFT", 8, by) - placeLabel:SetText(L["PLACEMENT"]) - placeLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - local anchorDrop = GUI:CreateDropdown(body, L["Anchor"], OPTS.ANCHOR_OPTIONS, group, "anchor", LayoutDebuffGroupRefresh) - anchorDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if anchorDrop.SetWidth then anchorDrop:SetWidth(bodyWidth - 10) end - by = by - 54 - - local oxSlider = GUI:CreateSlider(body, L["Offset X"], -150, 150, 1, group, "offsetX", - LayoutDebuffGroupRefresh, RefreshPlacedIndicators) - oxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if oxSlider.SetWidth then oxSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - local oySlider = GUI:CreateSlider(body, L["Offset Y"], -150, 150, 1, group, "offsetY", - LayoutDebuffGroupRefresh, RefreshPlacedIndicators) - oySlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if oySlider.SetWidth then oySlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- ── GROWTH SECTION ── - by = by - 10 - local growLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(growLabel, 8, "") - growLabel:SetPoint("TOPLEFT", 8, by) - growLabel:SetText(L["GROWTH"]) - growLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 18 - - local growthControl = GUI:CreateGrowthControl(body, group, "growDirection", LayoutDebuffGroupRefresh) - growthControl:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if growthControl.SetWidth then growthControl:SetWidth(bodyWidth - 10) end - by = by - 158 - - local iprSlider = GUI:CreateSlider(body, L["Icons Per Row"], 1, 20, 1, group, "iconsPerRow", - LayoutDebuffGroupRefresh, RefreshPlacedIndicators) - iprSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if iprSlider.SetWidth then iprSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - local spacingSlider = GUI:CreateSlider(body, L["Spacing"], -5, 20, 1, group, "spacing", - LayoutDebuffGroupRefresh, RefreshPlacedIndicators) - spacingSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if spacingSlider.SetWidth then spacingSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- Uniform per-group styling (filter-group idiom: one icon - -- size + slot cap for everything the categories match). - local sizeSlider = GUI:CreateSlider(body, L["Icon Size"], 8, 64, 1, group, "iconSize", - LayoutDebuffGroupRefresh, RefreshPlacedIndicators) - sizeSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if sizeSlider.SetWidth then sizeSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- Max Icons is STRUCTURAL (slot count is declared at container - -- build) — the factory Rebuild path handles it (OOC-deferred). - local maxSlider = GUI:CreateSlider(body, L["Max Icons"], 1, 20, 1, group, "maxIcons", - LayoutDebuffGroupRefresh, RefreshPlacedIndicators) - maxSlider:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if maxSlider.SetWidth then maxSlider:SetWidth(bodyWidth - 10) end - by = by - 54 - - -- ── SORT (Wave 2) ── per-group sort is TUNING: the factory - -- re-applies it in place via ApplyTuning (OOC-immediate; - -- self-defers in combat) — no rebuild. Fields are OPTIONAL on - -- the group (othersOnly idiom): absent = the family default - -- "TIME" (soonest-to-expire first — the old hardcode), read - -- through a customGet so legacy groups display correctly - -- without a write-on-open. - local sortMineCb -- forward capture: the dropdown greys it - local sortDrop = GUI:CreateDropdown(body, L["Sort Order"], OPTS.SORT_OPTIONS, nil, nil, function() - LayoutDebuffGroupRefresh() - if sortMineCb then sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "TIME")) end - end, function() return group.sortOrder or "TIME" end, - function(v) group.sortOrder = v end) - sortDrop:SetPoint("TOPLEFT", body, "TOPLEFT", 5, by) - if sortDrop.SetWidth then sortDrop:SetWidth(bodyWidth - 10) end - by = by - 54 - - sortMineCb = GUI:CreateCheckbox(body, L["My Auras First"], group, "sortMineFirst", LayoutDebuffGroupRefresh) - sortMineCb:SetPoint("TOPLEFT", 8, by) - sortMineCb:SetWidth(bodyWidth - 16) - sortMineCb.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] - sortMineCb:SetEnabled(DF:SortOrderSupportsMineFirst(group.sortOrder or "TIME")) - by = by - 26 - - local sortRevCb = GUI:CreateCheckbox(body, L["Reverse Order"], group, "sortReverse", LayoutDebuffGroupRefresh) - sortRevCb:SetPoint("TOPLEFT", 8, by) - sortRevCb:SetWidth(bodyWidth - 16) - sortRevCb.tooltip = L["Reverse the sort direction."] - by = by - 30 - - -- ── APPEARANCE (collapsible — the effect-card section idiom) ── - by = by - 10 - by = AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) - - local bodyH = -by + 12 - body:SetHeight(bodyH) - totalCardH = totalCardH + bodyH - end - - card:SetHeight(totalCardH) - yPos = yPos - totalCardH - 5 - end - end - - parent:SetHeight(max(-yPos + 20, 200)) -end - --- ============================================================ --- MAIN PAGE BUILD --- ============================================================ - -function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) - local prevDB = S.db -- capture before overwrite to detect mode switch - S.page = pageRef - S.db = dbRef - - local parent = S.page.child - - -- ======================================== - -- REUSE: If S.mainFrame already exists, S.db hasn't changed (same mode) AND the - -- frame dimensions are unchanged, just re-parent, show, and refresh. A mode - -- switch (Party↔Raid) changes S.db; an auto-layout switch keeps the SAME S.db - -- reference but changes frameWidth/Height — both must force a full rebuild so - -- the preview mock resizes to the active layout's frame size. - -- ======================================== - local _adFDB = (DF.GetDB and DF:GetDB((GUI and GUI.SelectedMode) or "party")) or {} - local _adW, _adH = _adFDB.frameWidth or 125, _adFDB.frameHeight or 64 - -- Auto-layout identity: two raid layouts share the SAME S.db proxy and may share - -- frame dimensions, so neither check below distinguishes them — without this, - -- switching between same-size raid layouts reuses the stale S.page. - local _adLayout = (DF.AutoProfilesUI and (DF.AutoProfilesUI.editingProfile or DF.AutoProfilesUI.activeRuntimeProfile)) or nil - -- Preset identity: switching the mode's preset keeps the same S.db/size/layout, - -- so without this the stale S.page (bound to the old preset) would be reused. - local _adPreset = DF.GetModeDesignerPresetName - and DF:GetModeDesignerPresetName("aura", (GUI and GUI.SelectedMode) or "party") - -- Editing identity: entering edit of the ACTIVE layout keeps the same table - -- object (editingProfile == activeRuntimeProfile), so _adLayout alone - -- misses the transition and the editing-banner offset is never applied. - local _adEditing = (DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing()) or false - if S.mainFrame and prevDB == dbRef - and S.mainFrame.dfBuiltFrameW == _adW and S.mainFrame.dfBuiltFrameH == _adH - and S.mainFrame.dfBuiltLayout == _adLayout - and S.mainFrame.dfBuiltPreset == _adPreset - and S.mainFrame.dfBuiltEditing == _adEditing then - S.mainFrame:SetParent(parent) - S.mainFrame:SetAllPoints() - S.mainFrame:Show() - DF:AuraDesigner_RefreshPage() - return - end - - -- Full build (first time, or mode switch) - if S.mainFrame then - S.mainFrame:Hide() - S.mainFrame:SetParent(nil) - end - wipe(placedIndicators) - wipe(expandedCards) - wipe(effectCardPool) - - S.activeTab = "effects" - S.activeBuffTab = "my" - S.activeFilter = "all" - -- A shared picker left open on the OLD S.rightPanel dies with it (its - -- close hook may already have run via the ancestor hide); drop the - -- handle so CloseADPicker can't poke a stale overlay. - S.adPickerDirty = false - S.adPickerHandle = nil - - -- Layout constants - local BANNER_H = 68 - local BUFFTAB_H = 30 -- main pool tab strip (My Buffs / Other Buffs) - local SECTION_GAP = 8 - - -- ======================================== - -- MAIN FRAME - -- ======================================== - S.mainFrame = CreateFrame("Frame", nil, parent) - S.mainFrame:SetAllPoints() - -- Record the frame dims this build was made for, so the reuse-guard above can - -- detect an auto-layout switch (same S.db, different frameWidth/Height) and rebuild. - S.mainFrame.dfBuiltFrameW = _adW - S.mainFrame.dfBuiltFrameH = _adH - S.mainFrame.dfBuiltLayout = _adLayout - S.mainFrame.dfBuiltPreset = _adPreset - S.mainFrame.dfBuiltEditing = _adEditing - -- Closing the settings window (or leaving this S.page) hides S.mainFrame with - -- no refresh pass, which would leave the rendered preview pool's border - -- animations ticking on the external driver (it ticks hidden secretRect - -- borders — see ClearPlacedIndicators). OnHide fires on effective-visibility - -- loss, so an ancestor hide (window close, S.page switch) reaches it too; the - -- reuse path re-renders via AuraDesigner_RefreshPage → RefreshPlacedIndicators - -- on return. S.mainFrame is created fresh per full build (the old one is hidden - -- and unparented above), so this hook lands exactly once per frame. - S.mainFrame:HookScript("OnHide", ClearPlacedIndicators) - - -- Override RefreshStates: Aura Designer uses its own layout system. - -- - -- This hook gets called by anything that walks the GUI parent chain - -- looking for a S.page with RefreshStates+children — including - -- CreateInfoBanner's TriggerHostRelayout after every measure cycle. - -- AuraDesigner_RefreshPage is a heavyweight rebuild (destroys + - -- recreates every effect card on the active tab), so firing it - -- from a banner's auto-resize cascade meant: each new banner from - -- S.BuildEffectsTab triggered SetText → schedule DoRecomputeHeight → - -- TriggerHostRelayout → S.page:RefreshStates → AuraDesigner_RefreshPage - -- → S.SwitchTab → S.BuildEffectsTab → create more banners → repeat at - -- ~9 Hz, locking up the GUI the moment the perf-warning banner - -- surfaced (because picking an animation triggered the chain). - -- - -- The fix: only call AuraDesigner_RefreshPage when the S.page - -- dimensions actually changed. GUI window resize cases (the real - -- reason this hook exists) still rebuild; banner-cascade-as-noop - -- cases stop the loop. - S.page.RefreshStates = function(self) - local pageH = self:GetHeight() - self.child:SetHeight(pageH) - local newW = GUI.contentFrame and (GUI.contentFrame:GetWidth() - 30) or nil - if self.child and newW then - self.child:SetWidth(newW) - end - -- Keep parent scroll at 0 — only the right panel should scroll - local parentScroll = self:GetParent() - if parentScroll and parentScroll.SetVerticalScroll then - parentScroll:SetVerticalScroll(0) - end - -- Skip the heavyweight rebuild when nothing actually changed — - -- only fire it on genuine size transitions (window resize / tab - -- switch / first show). - if self._lastRefreshStatesH == pageH and self._lastRefreshStatesW == newW then - -- Same-size revisit AFTER the OnHide hook cleared the canvas (tab - -- away + back re-enters ONLY through RefreshCached -> here; the - -- builder is cache-skipped). Repaint the pooled slots without the - -- full S.page rebuild: slot restyling creates no banners, so the - -- banner-cascade loop this early-out exists for cannot re-arm. - if S.placedCleared then - RefreshPlacedIndicators() - RefreshPreviewEffects() - end - return - end - self._lastRefreshStatesH = pageH - self._lastRefreshStatesW = newW - DF:AuraDesigner_RefreshPage() - end - - local yPos = -8 -- top gap; kept equal to the Text Designer's _tdTopY for a consistent header gap - -- While editing a raid auto-layout, the AutoProfiles editing banner is a ~50px - -- overlay anchored to the top of the content frame; this custom AD S.page lays - -- its own content out from the top too, so push everything down to clear it - -- (otherwise the editing banner sits on top of the enable banner / preset bar). - if DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() then - yPos = -56 - end - - -- ======================================== - -- ENABLE BANNER (full width) - -- ======================================== - S.enableBanner = CreateEnableBanner(S.mainFrame) - S.enableBanner:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) - S.enableBanner:SetPoint("TOPRIGHT", S.mainFrame, "TOPRIGHT", 0, yPos) - - -- No Copy / Sync pair here (every other mode-specific S.page has one). The one - -- key this S.page owns is the template NAME, and the template bar below sets - -- it directly — so Copy was "pick that name in the other tab" and Sync was a - -- link that could only ever hold one name in step. Sharing is now stated and - -- undone on the bar itself; see GUI:CreateDesignerPresetBar. - - -- ======================================== - -- PRESET BAR (which named preset this mode uses + library management) - -- Rides row 2 of the enable banner (left side; the spec dropdown holds the - -- right side). Compact icon buttons keep the row within the S.page width. - -- ======================================== - if GUI.CreateDesignerPresetBar then - local presetBar = GUI:CreateDesignerPresetBar(S.enableBanner, { - kind = "aura", - iconButtons = true, - getMode = function() return (GUI and GUI.SelectedMode) or "party" end, - onChange = function() - -- Re-invoke the build NEXT frame: the dfBuiltPreset guard then - -- forces a full rebuild so the editor rebinds to the newly chosen - -- preset. Deferred so we don't tear down the bar from inside its - -- own click handler. - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - if DF.BuildAuraDesignerPage then DF.BuildAuraDesignerPage(GUI, S.page, S.db) end - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - local E = DF.AuraDesigner and DF.AuraDesigner.Engine - if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end - end) - end - end, - }) - -- Row 2 reflows (B2): the spec dropdown moved onto the tab strip, so - -- the preset bar takes the whole row. - presetBar:SetPoint("LEFT", S.enableBanner, "LEFT", 10, -18) - presetBar:SetPoint("RIGHT", S.enableBanner, "RIGHT", -10, -18) - S.enableBanner.presetBar = presetBar - end - - yPos = yPos - (BANNER_H + SECTION_GAP) - - -- ======================================== - -- MAIN POOL TAB STRIP (B2/C2): My Buffs / Debuffs / Other Buffs, between the header - -- banner and the workspace. Visually distinct from the right panel's - -- underline sub-tabs: filled StyleButton pills, slightly larger, with the - -- relocated spec dropdown on the strip's right end (per the prototype — - -- the spec only applies to My Buffs). - -- ======================================== - local buffTabBar = CreateFrame("Frame", nil, S.mainFrame) - buffTabBar:SetHeight(BUFFTAB_H) - buffTabBar:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) - buffTabBar:SetPoint("TOPRIGHT", S.mainFrame, "TOPRIGHT", 0, yPos) - - -- The three pools differ on two axes the labels can't carry — WHOSE casts - -- count, and whether the pool is per-spec — so each tab explains itself on - -- hover. (Any Buff is caster-agnostic by default; "Others Only" is the - -- per-effect opt-in that ignores your own casts.) - local MAIN_TAB_DEFS = { - { key = "my", label = L["My Buffs"], tooltip = { - L["Buffs from your own class, and only when you cast them."], - L["Set up separately for each specialization."], - } }, - { key = "debuffs", label = L["Debuffs"], tooltip = { - L["Groups of debuffs picked by category — boss, crowd control, dispellable and so on — rather than one spell at a time."], - L["Shared across all your specializations."], - } }, - { key = "other", label = L["Any Buff"], tooltip = { - L["Any buff in the spell database, from any caster — including your own."], - L["Turn on Others Only for an effect to ignore your own casts."], - L["Shared across all your specializations."], - } }, - } - wipe(mainTabButtons) - local prevMainBtn - for _, def in ipairs(MAIN_TAB_DEFS) do - local btn = CreateFrame("Button", nil, buffTabBar, "BackdropTemplate") - GUI:StyleButton(btn, { height = BUFFTAB_H - 4, text = def.label, font = "DFFontHighlight" }) - btn.Text:ClearAllPoints() - btn.Text:SetPoint("CENTER", 0, 0) - btn:SetWidth(max(btn.Text:GetStringWidth() + 28, 96)) - if prevMainBtn then - btn:SetPoint("LEFT", prevMainBtn, "RIGHT", 4, 0) - else - btn:SetPoint("LEFT", buffTabBar, "LEFT", 0, 0) - end - local capturedKey = def.key - btn:SetScript("OnClick", function() SetMainTab(capturedKey) end) - -- HookScript, not SetScript: StyleButton owns OnEnter/OnLeave for the - -- hover wash, and replacing them would leave the tab stuck lit. - local tipTitle, tipLines = def.label, def.tooltip - btn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = tipTitle, lines = tipLines }) - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - btn:SetActive(S.activeBuffTab == def.key) - mainTabButtons[def.key] = btn - prevMainBtn = btn - end - - -- Relocated spec dropdown (right end of the strip) - local stripSpecLabel = buffTabBar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - stripSpecLabel:SetText(L["Spec:"]) - stripSpecLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - S.specDropdown, S.specDropdownUpdate = CreateSpecDropdown(buffTabBar) - S.specDropdown:SetSize(165, 22) - S.specDropdown:SetPoint("RIGHT", buffTabBar, "RIGHT", -5, 0) - stripSpecLabel:SetPoint("RIGHT", S.specDropdown, "LEFT", -4, 0) - UpdateSpecDropdownState() - - yPos = yPos - (BUFFTAB_H + SECTION_GAP) - - -- ======================================== - -- 50/50 SPLIT: LEFT PANEL + RIGHT PANEL - -- ======================================== - local splitContainer = CreateFrame("Frame", nil, S.mainFrame) - splitContainer:SetPoint("TOPLEFT", S.mainFrame, "TOPLEFT", 0, yPos) - splitContainer:SetPoint("BOTTOMRIGHT", S.mainFrame, "BOTTOMRIGHT", 0, 0) - S.mainFrame.splitContainer = splitContainer - - -- ── LEFT PANEL (frame preview) ── - S.leftPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") - S.leftPanel:SetPoint("TOPLEFT", 0, 0) - S.leftPanel:SetPoint("BOTTOMLEFT", 0, 0) - S.leftPanel:SetPoint("RIGHT", splitContainer, "CENTER", -3, 0) - -- NO border here — the inner preview container (CreateFramePreview) draws the - -- visible dim border. A border on both stacked to a brighter doubled line. - GUI:CreatePanelBackdrop(S.leftPanel, {border = false}) - - -- Frame preview (reuses existing CreateFramePreview with adapted anchoring) - S.origY_framePreview = 0 - S.framePreview = CreateFramePreview(S.leftPanel, 0, nil) - S.contentRightInset = 0 -- No right inset needed in new layout - - -- ── RIGHT PANEL (tabbed settings) ── - S.rightPanel = CreateFrame("Frame", nil, splitContainer, "BackdropTemplate") - S.rightPanel:SetPoint("TOPRIGHT", 0, 0) - S.rightPanel:SetPoint("BOTTOMRIGHT", 0, 0) - S.rightPanel:SetPoint("LEFT", splitContainer, "CENTER", 3, 0) -- 6px split gap (matches Text Designer) - GUI:CreatePanelBackdrop(S.rightPanel, {borderColor = {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5}}) - - -- ── TAB BAR ── (shared underline-tab style, mirroring the Pinned Frames - -- tabs: a transparent strip with a baseline; each tab is a StyleButton in - -- `tab` mode — faint cell when inactive, accent fill + underline + accent - -- label when active. Per-tab accent preserves each section's identity.) - S.tabBar = CreateFrame("Frame", nil, S.rightPanel, "BackdropTemplate") - S.tabBar:SetHeight(28) - -- Inset just inside the panel's border so the tabs don't overlap/overrun it. - S.tabBar:SetPoint("TOPLEFT", 4, -4) - S.tabBar:SetPoint("TOPRIGHT", -4, -4) - - -- Baseline under the whole strip; the active tab's underline sits on it. - local tabBaseline = S.tabBar:CreateTexture(nil, "ARTWORK") - tabBaseline:SetTexture("Interface\\Buttons\\WHITE8x8") - tabBaseline:SetHeight(1) - tabBaseline:SetPoint("BOTTOMLEFT", 0, 0) - tabBaseline:SetPoint("BOTTOMRIGHT", 0, 0) - tabBaseline:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - - local TAB_GAP = 4 - local TAB_DEFS = { - { key = "effects", label = L["Effects"], accent = nil }, -- theme-tracking - { key = "layout", label = L["Layout Groups"], accent = { r = 0.91, g = 0.66, b = 0.25 } }, - { key = "global", label = L["Global"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, - } - - wipe(tabButtons) - for i, def in ipairs(TAB_DEFS) do - local btn = CreateFrame("Button", nil, S.tabBar, "BackdropTemplate") - btn:SetHeight(28) - if i == 1 then - btn:SetPoint("TOPLEFT", 0, 0) - else - btn:SetPoint("TOPLEFT", tabButtons[TAB_DEFS[i-1].key], "TOPRIGHT", TAB_GAP, 0) - end - local provW = parent:GetWidth() - if provW < 100 and GUI and GUI.contentFrame then provW = GUI.contentFrame:GetWidth() end - if provW < 100 then provW = 600 end - btn:SetWidth(max(60, floor(((provW / 2) - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS))) - - -- Shared underline-tab styling; S.SwitchTab drives SetActive. label = btn.Text. - GUI:StyleButton(btn, { tab = true, text = def.label, accent = def.accent, font = "DFFontHighlight" }) - btn.label = btn.Text - - btn.tabKey = def.key - btn:SetScript("OnClick", function(self) - if self.dfDisabled then return end -- frosted (Effects on Debuffs) - S.SwitchTab(self.tabKey) - end) - - -- Effects is buff-pool-only (C2): frosted on the Debuffs tab — - -- category groups have no per-spell placed indicators. - if def.key == "effects" then - btn:HookScript("OnEnter", function(self) - if self.dfDisabled then - GUI:ShowTooltip(self, { - title = L["Effects"], - lines = { L["Not available for Debuffs. Use Layout Groups instead."] }, - }) - end - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - - tabButtons[def.key] = btn - end - UpdateLayoutTabState() - - -- Equal-width tabs (accounting for the gaps) on parent resize. - S.tabBar:SetScript("OnSizeChanged", function(self, w, h) - local n = #TAB_DEFS - local tabW = (w - (n - 1) * TAB_GAP) / n - for _, def in ipairs(TAB_DEFS) do - local btn = tabButtons[def.key] - if btn then btn:SetWidth(tabW) end - end - end) - - -- ── TAB CONTENT (scrollable) ── - S.tabScrollFrame = CreateFrame("ScrollFrame", nil, S.rightPanel, "ScrollFrameTemplate") - S.tabScrollFrame:SetPoint("TOPLEFT", S.tabBar, "BOTTOMLEFT", 0, 0) - S.tabScrollFrame:SetPoint("BOTTOMRIGHT", -22, 0) - - S.tabContentFrame = CreateFrame("Frame", nil, S.tabScrollFrame) - -- Pre-compute initial width from parent geometry so S.SwitchTab() has - -- accurate dimensions before the first layout pass fires OnSizeChanged. - local earlyW = parent:GetWidth() - if earlyW < 100 then earlyW = (GUI.contentFrame and GUI.contentFrame:GetWidth() or 600) - 30 end - S.tabContentFrame:SetWidth(max(1, (earlyW / 2) - 2 - 22)) - S.tabContentFrame:SetHeight(800) - S.tabScrollFrame:SetScrollChild(S.tabContentFrame) - DF.GUI.StyleScrollBar(S.tabScrollFrame) - - -- Match scroll child width to scroll frame - S.tabScrollFrame:SetScript("OnSizeChanged", function(self, w, h) - S.tabContentFrame:SetWidth(w) - end) - - -- Smooth scroll - local SCROLL_STEP = 30 - S.tabScrollFrame:SetScript("OnMouseWheel", function(self, delta) - local current = self:GetVerticalScroll() - local maxScroll = max(0, self:GetVerticalScrollRange()) - local newScroll = max(0, min(maxScroll, current - (delta * SCROLL_STEP))) - self:SetVerticalScroll(newScroll) - end) - S.tabContentFrame:EnableMouseWheel(true) - S.tabContentFrame:SetScript("OnMouseWheel", function(self, delta) - local p = self:GetParent() - if p and p:GetScript("OnMouseWheel") then - p:GetScript("OnMouseWheel")(p, delta) - end - end) - - -- ======================================== - -- POPULATE (new UI) - -- ======================================== - - -- Force initial width sync: OnSizeChanged won't fire until the frame renders, - -- but S.SwitchTab needs accurate widths now for slider/dropdown sizing. - -- Compute initial scroll content width from parent geometry. - -- S.rightPanel:GetWidth() returns 0 before the first layout pass, so we - -- calculate from the parent which already has valid geometry on a mode - -- switch (Party↔Raid). - local parentW = parent:GetWidth() - if parentW < 100 and GUI and GUI.contentFrame then parentW = GUI.contentFrame:GetWidth() end - if parentW < 100 then parentW = UIParent:GetWidth() / 2 end - local initW = (parentW / 2) - 2 - 22 -- half split minus gap minus scrollbar - if initW > 50 then - S.tabContentFrame:SetWidth(initW) - end - - S.SwitchTab("effects") - C_Timer.After(0, function() - if S.tabBar and S.tabBar:IsVisible() and S.tabBar:GetWidth() > 10 then - local tabW = (S.tabBar:GetWidth() - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS - for _, def in ipairs(TAB_DEFS) do - if tabButtons[def.key] then - tabButtons[def.key]:SetWidth(tabW) - end - end - end - end) - RefreshPlacedIndicators() - RefreshPreviewEffects() -end - --- ============================================================ --- REFRESH --- ============================================================ - -function DF:AuraDesigner_RefreshPage() - if not S.mainFrame then return end - - -- Account for editing banner offset (50px) when editing an auto layout - local editingOffset = 0 - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - editingOffset = 50 - end - S.mainFrame:ClearAllPoints() - S.mainFrame:SetPoint("TOPLEFT", S.mainFrame:GetParent(), "TOPLEFT", 0, -editingOffset) - S.mainFrame:SetPoint("BOTTOMRIGHT", S.mainFrame:GetParent(), "BOTTOMRIGHT", 0, 0) - - -- Check if spec changed - local currentSpec = ResolveSpec() - if currentSpec ~= S.selectedSpec then - S.selectedSpec = currentSpec - end - - -- Subtle class-color hint on the preview border, dimmed to 0.5 alpha so it - -- stays as quiet as the Text Designer's neutral border (just tinted to the - -- spec). Was previously full alpha = the harsh "white line" (white for Priest). - if S.framePreview then - local resolvedSpec = currentSpec or S.selectedSpec - local specInfoEntry = resolvedSpec and DF.AuraDesigner.SpecInfo[resolvedSpec] - local classToken = specInfoEntry and specInfoEntry.class - local classColor = classToken and RAID_CLASS_COLORS[classToken] - if classColor then - S.framePreview:SetBackdropBorderColor(classColor.r, classColor.g, classColor.b, 0.5) - else - S.framePreview:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - end - end - - -- Rebuild the current tab to reflect data changes - if S.activeTab and S.SwitchTab then - S.SwitchTab(S.activeTab) - end - - -- Refresh frame preview - RefreshPlacedIndicators() - RefreshPreviewEffects() - - -- Update enable state - if S.enableBanner then - S.enableBanner.checkbox:SetChecked(GetAuraDesignerDB().enabled) - end - -- Spec dropdown lives on the main tab strip (B2): refresh its text on - -- My Buffs / keep the greyed "shared across specs" caption on Other Buffs. - UpdateSpecDropdownState() - - -- Show/hide disabled overlay on the split container - if S.mainFrame.splitContainer then - local adEnabled = GetAuraDesignerDB().enabled - if not adEnabled then - if not S.mainFrame.disabledOverlay then - -- Shared with the Text Designer and Raid Auto Layouts; this S.page - -- only owns the extent (the whole split container) and the label. - local overlay = GUI:CreateDisabledOverlay(S.mainFrame.splitContainer, { - label = L["Aura Designer is disabled"], - }) - overlay:SetAllPoints() - S.mainFrame.disabledOverlay = overlay - end - S.mainFrame.disabledOverlay:Show() - else - if S.mainFrame.disabledOverlay then - S.mainFrame.disabledOverlay:Hide() - end - end - end - - -- Refresh buffs tab banner state if visible - local buffsPage = GUI and GUI.Pages and GUI.Pages["auras_buffs"] - if buffsPage and buffsPage.RefreshStates then - buffsPage:RefreshStates() - end - - -- 12.1: the native factory buff row DERIVES its Aura-Designer dedup set from the AD - -- config at build time. Indicator add/remove (and other config mutations) funnel - -- through this refresh but do NOT otherwise re-drive live frames, so poke the buff - -- row here — mirror the aura-blacklist S.page (InvalidateAuraLayout -> RefreshFactoryRows). - -- DriveBuffFactory rebuilds only when the excluded set actually moved (sig-gated), so a - -- navigation-only refresh is a cheap no-op. Factory-gated so the pre-12.1 path is untouched. - if DF.AuraContainer and DF.AuraContainer.IsSupported and DF.AuraContainer.IsSupported() - and DF.InvalidateAuraLayout then - DF:InvalidateAuraLayout() - end -end +DF.AuraDesigner._priv = P + +-- Load-time constants. Both used to be assigned inside BuildAuraDesignerPage, +-- which made them mutable and so un-splittable. Neither ever actually varies: +-- every caller arrives as DF.GUI -> SetupGUIPages -> here, and Adapter was only +-- ever assigned DF.AuraDesigner.Adapter. Resolving them at load lets each split +-- part re-declare them as plain aliases -- which is why 412 of this file's +-- reference sites needed no change at all. +-- Both source files load earlier in the TOC, so these are populated here. +local GUI = DF.GUI +local Adapter = DF.AuraDesigner.Adapter +-- (S.page and S.db are set per build on the state table) + +-- State +S.selectedSpec = nil -- Current spec key being viewed + +-- Reusable color constants: reference the shared GUI palette (same numeric +-- values, zero visual change) so they track any future palette change in +-- lockstep. GUI.lua loads before this file (see .toc), so DF.GUI.Colors is +-- populated at parse time. +local C_BACKGROUND = DF.GUI.Colors.background +-- (C_PANEL removed — unreferenced; reclaimed for the 200-locals ceiling.) +local C_ELEMENT = DF.GUI.Colors.element +local C_BORDER = DF.GUI.Colors.border +local C_HOVER = DF.GUI.Colors.hover +local C_TEXT = DF.GUI.Colors.text +local C_TEXT_DIM = DF.GUI.Colors.textDim + +-- Indicator type definitions +-- These option/label tables read L["..."]; at file scope that returns the enUS +-- baseline (the languageOverride overlay runs later, at ADDON_LOADED). Build +-- them in a registered refresh fn so Core rebuilds them after the overlay — +-- otherwise these dropdowns stay English. Value-keys (CENTER, RIGHT, …) and +-- the _order arrays are raw identifiers and must NOT be localized. +-- One namespace table instead of one local per option table (the main chunk +-- rides the Lua 5.1 200-locals ceiling; seven locals reclaimed to one). +local OPTS = {} +P.OPTS = OPTS + +local function RefreshLocaleStrings() + OPTS.INDICATOR_TYPES = { + { key = "icon", label = L["Icon"], placed = true }, + { key = "square", label = L["Square"], placed = true }, + { key = "bar", label = L["Bar"], placed = true }, + { key = "border", label = L["Border"], placed = false }, + { key = "healthbar", label = L["Health Bar Color"], placed = false }, + { key = "background", label = L["Background Color"], placed = false }, + { key = "nametext", label = L["Name Text Color"], placed = false }, + { key = "healthtext", label = L["Health Text Color"], placed = false }, + -- (framealpha removed 2026-07-25 — 12.1 casualty, see the Factory's CASUALTIES note. + -- Whole-frame alpha needs frame:SetAlpha gated on SECRET aura presence, and it also + -- fights the range / out-of-range alpha owners for the same property. It never had a + -- render path on the container engine — only the editor canvas applied it — so the + -- effect showed in the preview and did nothing in game.) + { key = "sound", label = L["Sound Alert"], placed = false }, + } + + OPTS.ANCHOR_OPTIONS = { + CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], + TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], + _order = {"TOPLEFT", "TOP", "TOPRIGHT", "LEFT", "CENTER", "RIGHT", "BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT"}, + } + + OPTS.GROWTH_OPTIONS = { + RIGHT = L["Right"], LEFT = L["Left"], UP = L["Up"], DOWN = L["Down"], + _order = {"RIGHT", "LEFT", "UP", "DOWN"}, + } + + OPTS.BORDER_STYLE_OPTIONS = { + SOLID = L["Solid Border"], ANIMATED = L["Animated Border"], DASHED = L["Dashed Border"], + GLOW = L["Glow"], CORNERS = L["Corners Only"], + _order = {"SOLID", "ANIMATED", "DASHED", "GLOW", "CORNERS"}, + } + + OPTS.HEALTHBAR_MODE_OPTIONS = { + Replace = L["Replace"], Tint = L["Tint"], + _order = {"Replace", "Tint"}, + } + + OPTS.BAR_ORIENT_OPTIONS = { + HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"], + _order = {"HORIZONTAL", "VERTICAL"}, + } + + -- Per-group Sort Order (Wave 2) — mirrors the aura rows' dropdown exactly. + OPTS.SORT_OPTIONS = { + DEFAULT = L["Default (Slot Order)"], TIME = L["Time Remaining"], NAME = L["Alphabetical"], + APPLIED = L["Order Applied"], + _order = {"DEFAULT", "TIME", "NAME", "APPLIED"}, + } +end + +RefreshLocaleStrings() +DF:RegisterLocaleRefresh(RefreshLocaleStrings) + +-- ============================================================ +-- HELPERS +-- ============================================================ + +local function MigrateToSpecScoped(adDB) + if not adDB then return end + + -- V1: migrate flat adDB.auras → spec-keyed adDB.auras + if not adDB._specScopedV1 then + if adDB.auras then + local isFlat = false + for _, val in pairs(adDB.auras) do + -- `border` counts too: an entry that only ever had its border + -- customised carries neither priority nor indicators, and reading it + -- as already-spec-scoped stamped _specScopedV1 without migrating — + -- those auras then rendered nothing, permanently. Core.lua's + -- equivalent probe has always included it; this copy had drifted. + if type(val) == "table" + and (val.priority ~= nil or val.indicators ~= nil or val.border ~= nil) then + isFlat = true + break + end + end + if isFlat then + local oldAuras = adDB.auras + local newAuras = {} + local auraToSpecs = {} + -- Build the name→specs map from the FULL trackable set, not the + -- curated TrackableAuras table alone: the real set is curated PLUS + -- the SpellDB class pool (Adapter:GetTrackableAuras). Using only the + -- curated half meant every pool-sourced aura the user had configured + -- matched nothing — and the wholesale `adDB.auras = newAuras` below + -- then deleted it, with no read-back and no log. + local AD = DF.AuraDesigner + local adapter = AD and AD.Adapter + if adapter and adapter.GetTrackableAuras and AD.SpecInfo then + for specKey in pairs(AD.SpecInfo) do + local ok, list = pcall(adapter.GetTrackableAuras, adapter, specKey) + if ok and type(list) == "table" then + for _, info in ipairs(list) do + if info.name then + if not auraToSpecs[info.name] then auraToSpecs[info.name] = {} end + tinsert(auraToSpecs[info.name], specKey) + end + end + end + end + end + -- Curated table as a fallback/supplement (also covers the case where + -- the adapter or the registry is not available at migration time). + local trackable = AD and AD.TrackableAuras + if trackable then + for specKey, auraList in pairs(trackable) do + for _, info in ipairs(auraList) do + local seen = auraToSpecs[info.name] + if not seen then + auraToSpecs[info.name] = { specKey } + else + local dup = false + for _, s in ipairs(seen) do + if s == specKey then dup = true; break end + end + if not dup then tinsert(seen, specKey) end + end + end + end + end + + local orphans, orphanCount = nil, 0 + for auraName, auraCfg in pairs(oldAuras) do + local specs = auraToSpecs[auraName] + if specs then + for _, specKey in ipairs(specs) do + if not newAuras[specKey] then newAuras[specKey] = {} end + newAuras[specKey][auraName] = DF:DeepCopy(auraCfg) + end + else + -- NEVER drop it. Park unmatched configs where they can be + -- recovered rather than replacing the table out from under + -- them (a SpellDB regen that renames a spell would otherwise + -- destroy that aura's config on the next load). + orphans = orphans or {} + orphans[auraName] = DF:DeepCopy(auraCfg) + orphanCount = orphanCount + 1 + end + end + if orphans then + adDB._unscopedAuras = orphans + if DF.DebugWarn then + DF:DebugWarn("AD", + "spec-scope migration: %d aura config(s) matched no spec; parked in _unscopedAuras", + orphanCount) + end + end + adDB.auras = newAuras + end + end + adDB._specScopedV1 = true + end + + -- V2: migrate flat adDB.layoutGroups array → spec-keyed + if not adDB._specScopedV2 then + if adDB.layoutGroups then + -- Detect flat array: first entry has numeric key and .id field + local isFlat = false + for k, v in pairs(adDB.layoutGroups) do + if type(k) == "number" and type(v) == "table" and v.id ~= nil then + isFlat = true + break + end + end + if isFlat then + local oldGroups = adDB.layoutGroups + local newGroups = {} + -- For each group, find which specs its member auras belong to + local auraToSpecs = {} + if adDB.auras then + for specKey, specAuras in pairs(adDB.auras) do + if type(specAuras) == "table" then + for auraName in pairs(specAuras) do + if not auraToSpecs[auraName] then auraToSpecs[auraName] = {} end + auraToSpecs[auraName][specKey] = true + end + end + end + end + for _, group in ipairs(oldGroups) do + -- Determine which specs this group's members belong to + local targetSpecs = {} + if group.members then + for _, member in ipairs(group.members) do + local specs = auraToSpecs[member.auraName] + if specs then + for specKey in pairs(specs) do + targetSpecs[specKey] = true + end + end + end + end + -- Copy group to each relevant spec, filtering members + for specKey in pairs(targetSpecs) do + if not newGroups[specKey] then newGroups[specKey] = {} end + local groupCopy = DF:DeepCopy(group) + -- Filter members to only those that exist in this spec + if groupCopy.members then + local filtered = {} + for _, member in ipairs(groupCopy.members) do + local specs = auraToSpecs[member.auraName] + if specs and specs[specKey] then + tinsert(filtered, member) + end + end + groupCopy.members = filtered + end + tinsert(newGroups[specKey], groupCopy) + end + end + adDB.layoutGroups = newGroups + end + end + -- Migrate nextLayoutGroupID to per-spec too (just keep global as fallback) + adDB._specScopedV2 = true + end +end + +-- Expose for Engine.lua and post-import use +DF.MigrateAuraDesignerSpecScope = MigrateToSpecScoped + +-- ============================================================ +-- STAGE 5.1b — ICON BORDER KEY MIGRATION +-- Renames the legacy per-aura icon border keys to the canonical +-- CreateBorderControls naming (matches every other unified-border +-- consumer in the addon): +-- borderEnabled → ShowBorder +-- borderThickness → BorderSize +-- borderInset → BorderInset (case-only rename) +-- +-- Walks every aura × every storage shape (typeKey-keyed sub-config +-- and the newer indicators[] array) for every spec. Idempotent — +-- only renames when the new key is nil, so a partially-migrated +-- config is safe. +-- +-- Scope: icon (Stage 5.1) + square (Stage 5.2). Bar (Stage 5.3) still +-- reuses some of the same legacy key names (borderThickness, borderInset), +-- so we stay type-scoped — only rename an instance's keys once that +-- indicator type has migrated to the unified backend. The icon and square +-- differ only in the enable key: icon used `borderEnabled`, square used +-- `showBorder`; both fold to canonical `ShowBorder`. +-- ============================================================ + +-- Backfill canonical ONLY when absent, but ALWAYS strip the legacy key — even +-- when the canonical key already exists. A block edited in the new GUI writes +-- canonical keys but leaves the legacy ones behind; the old "swap-and-nil only +-- if canonical == nil" form then skipped the strip, leaving the legacy key to +-- linger forever (the half-migrated state). Mirrors renameBorderTypeKeys. +local function renameIconBorderKeys(t) + if type(t) ~= "table" then return end + if t.borderEnabled ~= nil then + if t.ShowBorder == nil then t.ShowBorder = t.borderEnabled end + t.borderEnabled = nil + end + if t.borderThickness ~= nil then + if t.BorderSize == nil then t.BorderSize = t.borderThickness end + t.borderThickness = nil + end + if t.borderInset ~= nil then + if t.BorderInset == nil then t.BorderInset = t.borderInset end + t.borderInset = nil + end +end + +-- Square (Stage 5.2): border-key renames ONLY. The square's enable key is +-- `showBorder` (vs the icon's `borderEnabled`). Deliberately does NOT touch +-- `expiringPulsate` — on the square that's the FILL pulse, a different effect +-- from the icon's border DF_PULSATE, so it stays a boolean. +local function renameSquareBorderKeys(t) + if type(t) ~= "table" then return end + -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. + if t.showBorder ~= nil then + if t.ShowBorder == nil then t.ShowBorder = t.showBorder end + t.showBorder = nil + end + if t.borderThickness ~= nil then + if t.BorderSize == nil then t.BorderSize = t.borderThickness end + t.borderThickness = nil + end + if t.borderInset ~= nil then + if t.BorderInset == nil then t.BorderInset = t.borderInset end + t.borderInset = nil + end +end + +-- Bar (Stage 5.3): border-key renames. Enable key is `showBorder` (like the +-- square); the bar also carries a static `borderColor` table → canonical +-- `BorderColor`. The bar has no legacy inset key. +local function renameBarBorderKeys(t) + if type(t) ~= "table" then return end + -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. + if t.showBorder ~= nil then + if t.ShowBorder == nil then t.ShowBorder = t.showBorder end + t.showBorder = nil + end + if t.borderThickness ~= nil then + if t.BorderSize == nil then t.BorderSize = t.borderThickness end + t.borderThickness = nil + end + if t.borderColor ~= nil then + if t.BorderColor == nil then t.BorderColor = t.borderColor end + t.borderColor = nil + end +end + +-- Border-type indicator (Stage 5.4): its legacy `style` enum maps onto a +-- DF.Border style + animation combo. One-way, lossy (the 5 styles become +-- canonical Style/Animation combinations). Gated on `style` being present so +-- it runs once. +local function renameBorderTypeKeys(t) + if type(t) ~= "table" then return end + -- The legacy `style` key is what forces the old render path (Indicators.lua's + -- `config.style and BuildBorderTypeSpec(...)`), so its presence is what we key + -- on. Crucially we run EVEN WHEN BorderStyle already exists: a block edited in + -- the new GUI writes canonical keys but leaves `style` behind, so it must still + -- get stripped or it keeps rendering via the legacy builder (the half-migrated + -- bug — legacy color/thickness shadowing the GUI's BorderColor/BorderSize). + if t.style == nil then return end + local thickness = t.thickness or 2 + local inset = t.inset or 0 + local color = t.color or { r = 0, g = 0, b = 0, a = 1 } + local legacy = { Solid = "SOLID", Glow = "GLOW", Pulse = "SOLID" } + local style = legacy[t.style] or t.style or "SOLID" + -- Fold legacy → canonical ONLY where the canonical key is absent, so we never + -- overwrite edits the user already made in the new GUI (BorderColor/BorderSize). + if t.ShowBorder == nil then t.ShowBorder = true end + if t.BorderInset == nil then t.BorderInset = inset end + if t.BorderColor == nil then t.BorderColor = color end + if t.BorderStyle == nil then + if style == "GLOW" then + t.BorderStyle = "TEXTURE" + if t.BorderTexture == nil then t.BorderTexture = "DF Glow" end + if t.BorderSize == nil then t.BorderSize = thickness end + elseif style == "DASHED" or style == "ANIMATED" then + -- The legacy dashed/animated styles drew a size-0 border made visible + -- ONLY by a container border animation, which 12.1 retired (aura buttons + -- go forbidden while auras are secret). Fold them to a visible static + -- border so an upgraded config renders something rather than nothing. + t.BorderStyle = "SOLID" + if t.BorderSize == nil then t.BorderSize = thickness end + elseif style == "CORNERS" then + t.BorderStyle = "SOLID" + if t.BorderSize == nil then t.BorderSize = 0 end + if t.BorderAnimationType == nil then + t.BorderAnimationType = "CORNERS_ONLY" + t.BorderAnimationThickness = thickness + t.BorderAnimationColor = color + end + else -- SOLID (and anything unknown) + t.BorderStyle = "SOLID" + if t.BorderSize == nil then t.BorderSize = thickness end + end + elseif t.BorderSize == nil then + -- BorderStyle already set (by the GUI); just backfill size from legacy. + t.BorderSize = thickness + end + -- Strip legacy keys so rendering routes through DF.Border:BuildSpec. + t.style = nil; t.thickness = nil; t.inset = nil; t.color = nil +end + +local function MigrateIconBorderKeysOnAuras(specAuras) + if type(specAuras) ~= "table" then return end + for _, auraCfg in pairs(specAuras) do + if type(auraCfg) == "table" then + -- Old shape: auraCfg. sub-config. + if auraCfg.icon then renameIconBorderKeys(auraCfg.icon) end + if auraCfg.square then renameSquareBorderKeys(auraCfg.square) end + if auraCfg.bar then renameBarBorderKeys(auraCfg.bar) end + if auraCfg.border then renameBorderTypeKeys(auraCfg.border) end + -- New shape: auraCfg.indicators[i] — each instance carries its + -- own border keys when the user has overridden defaults. + if auraCfg.indicators then + for _, ind in ipairs(auraCfg.indicators) do + if ind.type == "icon" then + renameIconBorderKeys(ind) + elseif ind.type == "square" then + renameSquareBorderKeys(ind) + elseif ind.type == "bar" then + renameBarBorderKeys(ind) + elseif ind.type == "border" then + renameBorderTypeKeys(ind) + end + end + end + end + end +end + +-- Run the icon/square/bar/border key renames over one `.auras` table, handling +-- both the flat ({ auraName → auraCfg }) and spec-scoped +-- ({ specKey → { auraName → auraCfg } }) shapes. Mirrors +-- MigrateAuraDesignerToInstances' shape detection so we stay correct whether the +-- spec-scope migration ran before us or not. +local function MigrateBorderKeysOnAurasTable(auras) + if type(auras) ~= "table" then return end + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + MigrateIconBorderKeysOnAuras(auras) -- flat + else + for _, specAuras in pairs(auras) do -- spec-scoped + MigrateIconBorderKeysOnAuras(specAuras) + end + end + end + break -- Only check first entry for shape detection + end +end + +local function MigrateAuraDesignerIconBorderKeys(modeDb) + local adDB = modeDb and modeDb.auraDesigner + if adDB and adDB.auras then + MigrateBorderKeysOnAurasTable(adDB.auras) + end +end + +-- The Designer Presets rework relocated AD aura configs into +-- profile.auraDesignerPresets[name].auras. MigrateAuraDesignerIconBorderKeys +-- only walks the legacy per-mode modeDb.auraDesigner location, so preset-nested +-- border blocks were never folded — they kept their legacy `style` (rendering via +-- the old builder) while the GUI wrote canonical keys onto them (the half-migrated +-- state). Walk every preset's auras so those blocks get folded + `style` stripped. +local function MigrateAuraDesignerPresetBorderKeys(profile) + local presets = profile and profile.auraDesignerPresets + if type(presets) ~= "table" then return end + for _, preset in pairs(presets) do + if type(preset) == "table" then + MigrateBorderKeysOnAurasTable(preset.auras) + end + end +end + +DF.MigrateAuraDesignerIconBorderKeys = MigrateAuraDesignerIconBorderKeys +DF.MigrateAuraDesignerPresetBorderKeys = MigrateAuraDesignerPresetBorderKeys + +-- Lazy, flag-gated border-key fold. This is the one that actually matters for +-- live frames: the ADDON_LOADED passes above clean the STORED tables (modeDb +-- auraDesigner + presets), but render resolves its config through +-- DF:ResolveAuraDesigner / GetModeBaseAuraDesigner, which can hand back a +-- resolved / auto-layout-overlay table the load-time passes never touched. +-- Mirroring MigrateToSpecScoped's lazy-on-access pattern, we fold the border keys +-- on the EXACT adDB about to be rendered/edited, gated by `_borderKeysFoldedV1` +-- so it runs once per table. (Idempotent if the table is rebuilt each access.) +local function MigrateBorderKeysLazy(adDB) + if type(adDB) ~= "table" or adDB._borderKeysFoldedV1 then return end + if adDB.auras then MigrateBorderKeysOnAurasTable(adDB.auras) end + adDB._borderKeysFoldedV1 = true +end +DF.MigrateAuraDesignerBorderKeysLazy = MigrateBorderKeysLazy + +-- Same lazy-on-access pattern for the type-keyed → instances[] migration. It +-- only ran at ADDON_LOADED on modeDb.auraDesigner, so an imported preset still in +-- the legacy type-keyed shape (icon/square/bar sub-tables, no `indicators`) would +-- render NOTHING — the engine reads `auraCfg.indicators`. Reuses the existing, +-- idempotent DF.MigrateAuraDesignerToInstances (guarded per-aura by `not +-- auraCfg.indicators`) via a thin modeDb wrapper, so there's no duplicated +-- conversion logic. The resolved adDB IS the preset by reference, so folding it +-- also cleans the stored data. Runs before the border fold to match load order. +local function MigrateInstancesLazy(adDB) + if type(adDB) ~= "table" or adDB._instancesFoldedV1 then return end + if DF.MigrateAuraDesignerToInstances and adDB.auras then + DF.MigrateAuraDesignerToInstances({ auraDesigner = adDB }) + end + adDB._instancesFoldedV1 = true +end +DF.MigrateAuraDesignerInstancesLazy = MigrateInstancesLazy + +-- Lazy, flag-gated Priority-scale flip (old lower-wins → new higher-wins). Same +-- resolve-time, once-per-table pattern as the border-key fold above: it runs on +-- the EXACT adDB about to be rendered/edited, so auto-layout overlays, presets, +-- and the legacy inline config are all covered AT POINT OF USE, gated by +-- `_priorityHigherWinsV1`. Fresh configs are born flagged (NewAuraDesignerConfig) +-- and exports/imports carry the flag on the copied table, so only genuine pre-4.6 +-- data is ever flipped — and exactly once. Replaces the old one-shot ADDON_LOADED +-- walker, which misclassified flat auras with no indicators and double-flipped +-- newly-created / imported profiles into corruption. +local function FlipAuraPriority(auraCfg) + if type(auraCfg) == "table" and type(auraCfg.priority) == "number" then + local p = math.floor(auraCfg.priority + 0.5) + if p < 1 then p = 1 elseif p > 10 then p = 10 end + auraCfg.priority = 11 - p + end +end +local function MigratePrioritiesLazy(adDB) + if type(adDB) ~= "table" or adDB._priorityHigherWinsV1 then return end + local auras = adDB.auras + if type(auras) == "table" then + -- Shape detection off the first entry only, matching MigrateBorderKeysOnAurasTable. + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + for _, auraCfg in pairs(auras) do FlipAuraPriority(auraCfg) end -- flat: auras[name] + else + for _, specAuras in pairs(auras) do -- spec: auras[spec][name] + if type(specAuras) == "table" then + for _, auraCfg in pairs(specAuras) do FlipAuraPriority(auraCfg) end + end + end + end + end + break + end + end + adDB._priorityHigherWinsV1 = true +end +DF.MigrateAuraDesignerPrioritiesLazy = MigratePrioritiesLazy + +-- Per-indicator Frame Level became an ABSOLUTE offset from the unit frame (the render used +-- to add 40 to whatever was stored). Shifts every stored value by that same 40 so no +-- indicator moves. Runs on the RESOLVED adDB -- presets, auto-layout overlays and the legacy +-- inline config are all covered at point of use -- mirroring MigratePrioritiesLazy. +local function ShiftIndicatorLevels(auraCfg) + if type(auraCfg) ~= "table" then return end + local inds = auraCfg.indicators + if type(inds) ~= "table" then return end + for _, ind in pairs(inds) do + if type(ind) == "table" and tonumber(ind.frameLevel) then + ind.frameLevel = tonumber(ind.frameLevel) + 40 + end + end +end +local function MigrateAbsoluteLevelsLazy(adDB) + if type(adDB) ~= "table" or adDB._absoluteFrameLevelV1 then return end + local auras = adDB.auras + if type(auras) == "table" then + -- Shape detection off the first entry only, matching MigratePrioritiesLazy. + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + for _, auraCfg in pairs(auras) do ShiftIndicatorLevels(auraCfg) end + else + for _, specAuras in pairs(auras) do + if type(specAuras) == "table" then + for _, auraCfg in pairs(specAuras) do ShiftIndicatorLevels(auraCfg) end + end + end + end + end + break + end + end + adDB._absoluteFrameLevelV1 = true +end + +-- V2 — undo the two places V1 got AD's level wrong. Read this before touching either. +-- +-- AD's real level has always been 40 (the buff-icon band). Commit 47075137 then added +-- `indicatorFrameLevel = 30` as a Config default while the render still computed +-- `40 + stored`, so 30 was written into a field that meant a NUDGE — it reads like the +-- status-icon convention (30) copied into the wrong units. Effect: 40 + 30 = 70. +-- +-- V1 above then made levels absolute and added the base back, so a spurious nudge of 30 +-- became a stored 70. Correct logic, bad input: it preserved the mistake permanently, and +-- 70 sits ABOVE the defensive icon (65) — which is meant to be the top layer. +-- +-- V1 also only walked auraCfg.indicators. It never shifted adDB.defaults.indicatorFrameLevel, +-- so that key was left holding the pre-absolute 30 while every per-indicator value moved to +-- the absolute scale. The two numbers have been in different units ever since, which is why +-- the Default Frame Level slider reads 30 on a profile whose indicators all render at 70. +-- +-- So V2 restores the baseline: +-- defaults.indicatorFrameLevel == 30 -> 40 +-- per-indicator frameLevel == 70 -> 40 +-- +-- ⚠ Both rewrites are value-targeted, not blanket. 30 and 70 are the exact fingerprints of +-- the bad seed. A deliberate absolute 30 is not a thing anyone would choose (it puts AD +-- UNDER the buff/debuff rows at 40), and 70 is only reachable from the seeded nudge. The +-- accepted cost: someone who genuinely nudged +30 for a reason loses it and lands at the +-- baseline. That is judged the right trade while 12.1 is unshipped and the alpha population +-- is small — an untouched profile drawing AD over the defensive icon is the worse default. +-- Any OTHER stored level is left exactly as it is; those are real user choices. +local function ResetSeededIndicatorLevels(auraCfg) + if type(auraCfg) ~= "table" then return end + local inds = auraCfg.indicators + if type(inds) ~= "table" then return end + for _, ind in pairs(inds) do + if type(ind) == "table" and tonumber(ind.frameLevel) == 70 then + ind.frameLevel = 40 + end + end +end +local function MigrateAbsoluteLevelsV2Lazy(adDB) + if type(adDB) ~= "table" or adDB._absoluteFrameLevelV2 then return end + + local defs = adDB.defaults + if type(defs) == "table" and tonumber(defs.indicatorFrameLevel) == 30 then + defs.indicatorFrameLevel = 40 + end + + local auras = adDB.auras + if type(auras) == "table" then + -- Same shape detection as V1 (flat auraCfg vs spec-scoped), for the same reason. + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + for _, auraCfg in pairs(auras) do ResetSeededIndicatorLevels(auraCfg) end + else + for _, specAuras in pairs(auras) do + if type(specAuras) == "table" then + for _, auraCfg in pairs(specAuras) do ResetSeededIndicatorLevels(auraCfg) end + end + end + end + end + break + end + end + + adDB._absoluteFrameLevelV2 = true +end + +DF.MigrateAuraDesignerAbsoluteLevelsLazy = MigrateAbsoluteLevelsLazy +DF.MigrateAuraDesignerAbsoluteLevelsV2Lazy = MigrateAbsoluteLevelsV2Lazy + + +-- Lazy, flag-gated ONE-TIME refresh of the AD global text defaults to the Midnight +-- baseline: DF Roboto SemiBold + drop shadow, 1.2 duration scale, stack count seated +-- bottom-right (2,-2). Pre-12.1 the AD shipped Friz Quadrata / plain OUTLINE / 1.0 / +-- centred stacks — WoW-generic values nobody picks deliberately (same reasoning as the +-- settings-panel font flip, Core.lua's _settingsFontRobotoDefaultV1). Runs on the +-- RESOLVED adDB (presets, auto-layout overlays, and the legacy inline config all +-- covered at point of use), flipping ONLY where the value is still the EXACT old +-- default — so any explicit choice sticks, and placed indicators that already store +-- their own values are untouched. +-- durationColorByTime is DELIBERATELY not flipped: existing profiles keep their +-- duration colours (a stored OFF is as likely a choice as a leftover). New / reset +-- profiles pick up the new ON default via the Config factory, newly PLACED +-- indicators always stamp ON (EnsureTypeConfig, v4 parity), and the Factory render +-- resolves nil-instance indicators through adDB.defaults (resolveDefCBT) so the +-- stored value actually governs. +-- MUST be wired into BOTH this editor accessor AND the Factory render runner +-- (Factory.lua ~2270), or the editor shows the new values while live frames render the +-- old ones until a manual re-entry forces a rebuild. +local function MigrateDefaultRefreshLazy(adDB) + if type(adDB) ~= "table" or adDB._adDefaultRefreshV1 then return end + local d = adDB.defaults + if type(d) == "table" then + if d.durationFont == "Friz Quadrata TT" then d.durationFont = "DF Roboto SemiBold" end + if d.stackFont == "Friz Quadrata TT" then d.stackFont = "DF Roboto SemiBold" end + if d.durationOutline == "OUTLINE" then d.durationOutline = "SHADOW;OUTLINE" end + if d.stackOutline == "OUTLINE" then d.stackOutline = "SHADOW;OUTLINE" end + if d.durationScale == 1 then d.durationScale = 1.2 end -- 1.0 == 1 in Lua + if d.stackX == 0 then d.stackX = 2 end + if d.stackY == 0 then d.stackY = -2 end + end + adDB._adDefaultRefreshV1 = true +end +DF.MigrateAuraDesignerDefaultRefreshLazy = MigrateDefaultRefreshLazy + +local function GetAuraDesignerDB() + -- The editor is mode-tabbed: it edits the preset the active mode uses + -- (party → its assigned preset, etc.). Because edited == used, live + -- frames stay in sync with the editor automatically. + -- Base variant: the editor edits your base raid preset, not the active runtime + -- auto-layout's overlay (it edits the layout only while IN edit-auto-layout). + local adDB + if DF.GetModeBaseAuraDesigner then + local mode = (GUI and GUI.SelectedMode) or "party" + adDB = DF:GetModeBaseAuraDesigner(mode) + end + -- Pre-migration / very-early fallback to the legacy inline config. + adDB = adDB or (S.db and S.db.auraDesigner) + if adDB and (not adDB._specScopedV1 or not adDB._specScopedV2) then + MigrateToSpecScoped(adDB) + end + MigrateInstancesLazy(adDB) + MigrateBorderKeysLazy(adDB) + MigratePrioritiesLazy(adDB) + -- MUST match the render-path list in Factory.lua exactly. If the editor resolves an adDB + -- the render has not touched yet (a preset, or an auto-layout overlay not currently shown) + -- and skips a migration, the editor shows UN-migrated values -- and anything saved from that + -- state gets migrated a second time when the render finally runs. + MigrateAbsoluteLevelsLazy(adDB) + MigrateAbsoluteLevelsV2Lazy(adDB) + MigrateDefaultRefreshLazy(adDB) + return adDB +end +P.GetAuraDesignerDB = GetAuraDesignerDB + +local function GetThemeColor() + return GUI.GetThemeColor() +end +P.GetThemeColor = GetThemeColor + +local function ApplyBackdrop(frame, bgColor, borderColor) + -- Build through the shared GUI backdrop once per frame, then push only + -- vertex colours below. SetBackdrop is a full rebuild, so keeping it off the + -- re-render path still matters when the AD effects list rebuilds. + if not frame.dfAD_backdropApplied then + DF.GUI:CreateElementBackdrop(frame) + frame.dfAD_backdropApplied = true + end + + if bgColor then + local r, g, b = bgColor.r, bgColor.g, bgColor.b + local a = bgColor.a or 1 + if frame.dfAD_bgR ~= r or frame.dfAD_bgG ~= g or frame.dfAD_bgB ~= b or frame.dfAD_bgA ~= a then + frame:SetBackdropColor(r, g, b, a) + frame.dfAD_bgR, frame.dfAD_bgG, frame.dfAD_bgB, frame.dfAD_bgA = r, g, b, a + end + end + + if borderColor then + local r, g, b = borderColor.r, borderColor.g, borderColor.b + local a = borderColor.a or 1 + if frame.dfAD_borderR ~= r or frame.dfAD_borderG ~= g or frame.dfAD_borderB ~= b or frame.dfAD_borderA ~= a then + frame:SetBackdropBorderColor(r, g, b, a) + frame.dfAD_borderR, frame.dfAD_borderG, frame.dfAD_borderB, frame.dfAD_borderA = r, g, b, a + end + end +end +P.ApplyBackdrop = ApplyBackdrop + +-- ============================================================ +-- COLLAPSIBLE CARD SHELL +-- The effects list, the groups list and the debuff-category list each build the +-- same thing: a card pinned to the parent's width, a 30px header button on top, +-- and a chevron at its left edge that flips with the expanded state. Past that +-- point the three diverge completely -- a spell icon and type badge, a group +-- name and link count, a list of categories -- so this owns only the shell and +-- hands back the chevron for the caller to anchor its own content to. +-- +-- opts = { yPos, expanded, borderColor, chevronColor (default C_TEXT_DIM) } +-- Returns card, header, chevron. +-- ============================================================ +local CARD_HEADER_HEIGHT = 30 +local CHEVRON_EXPANDED = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more" +local CHEVRON_COLLAPSED = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right" + +local function CreateCardShell(parent, opts) + local card = CreateFrame("Frame", nil, parent, "BackdropTemplate") + card:SetPoint("TOPLEFT", 8, opts.yPos) + card:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + + local header = CreateFrame("Button", nil, card, "BackdropTemplate") + header:SetHeight(CARD_HEADER_HEIGHT) + header:SetPoint("TOPLEFT", 0, 0) + header:SetPoint("TOPRIGHT", 0, 0) + ApplyBackdrop(header, C_ELEMENT, opts.borderColor) + + local chevron = header:CreateTexture(nil, "OVERLAY") + chevron:SetSize(12, 12) + chevron:SetPoint("LEFT", 8, 0) + chevron:SetTexture(opts.expanded and CHEVRON_EXPANDED or CHEVRON_COLLAPSED) + local cc = opts.chevronColor or C_TEXT_DIM + chevron:SetVertexColor(cc.r, cc.g, cc.b) + + return card, header, chevron +end +P.CreateCardShell = CreateCardShell + +-- ============================================================ +-- BUFF COEXISTENCE POPUP +-- Shown once when the user enables Aura Designer, asking whether +-- to keep standard buff icons or let AD fully replace them. +-- ============================================================ + +-- (S.buffCoexistPopup declared on the state table) + +local function ShowBuffCoexistPopup(onConfirm, onCancel) + if not S.buffCoexistPopup then + local f = CreateFrame("Frame", "DFADBuffPopup", UIParent, "BackdropTemplate") + f:SetSize(420, 130) + f:SetPoint("CENTER") + f:SetFrameStrata("FULLSCREEN_DIALOG") + f:SetFrameLevel(250) + f:EnableMouse(true) + local tc = GetThemeColor() + ApplyBackdrop(f, {r = 0.10, g = 0.10, b = 0.10, a = 0.98}, {r = tc.r, g = tc.g, b = tc.b, a = 1}) + + -- Thin accent stripe along the top + local stripe = f:CreateTexture(nil, "OVERLAY") + stripe:SetColorTexture(tc.r, tc.g, tc.b, 0.8) + stripe:SetHeight(2) + stripe:SetPoint("TOPLEFT", 1, -1) + stripe:SetPoint("TOPRIGHT", -1, -1) + f._stripe = stripe + + local title = f:CreateFontString(nil, "OVERLAY", "DFFontNormal") + title:SetPoint("TOP", 0, -12) + title:SetText(L["Aura Designer"]) + title:SetTextColor(tc.r, tc.g, tc.b) + + local desc = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + desc:SetPoint("TOP", title, "BOTTOM", 0, -6) + desc:SetWidth(390) + desc:SetText(L["Would you like to keep standard buff icons alongside\nAura Designer, or let it fully replace them?"]) + desc:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + desc:SetJustifyH("CENTER") + + local function MakeButton(parent, text, xOff) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + btn:SetPoint("BOTTOM", parent, "BOTTOM", xOff, 14) + DF.GUI:StyleButton(btn, { width = 170, height = 28, text = text }) + btn.text = btn.Text + return btn + end + + f.keepBtn = MakeButton(f, L["Keep Buffs"], -95) + f.replaceBtn = MakeButton(f, L["Replace Buffs"], 95) + + -- Close on Escape. SetPropagateKeyboardInput is protected in combat + -- for insecure code: skip it there (keys propagate anyway — ESC just + -- hides the popup). + f:SetScript("OnKeyDown", function(self, key) + if key == "ESCAPE" then + if not InCombatLockdown() then self:SetPropagateKeyboardInput(false) end + self:Hide() + if self._onCancel then self._onCancel() end + else + if not InCombatLockdown() then self:SetPropagateKeyboardInput(true) end + end + end) + + S.buffCoexistPopup = f + end + + local f = S.buffCoexistPopup + f._onCancel = onCancel + + f.keepBtn:SetScript("OnClick", function() + f:Hide() + if onConfirm then onConfirm(true) end + end) + f.replaceBtn:SetScript("OnClick", function() + f:Hide() + if onConfirm then onConfirm(false) end + end) + + f:Show() +end +P.ShowBuffCoexistPopup = ShowBuffCoexistPopup + +-- Get or resolve the active spec key from settings +local function ResolveSpec() + local adDB = GetAuraDesignerDB() + if adDB.spec == "auto" then + return Adapter:GetPlayerSpec() + end + return adDB.spec +end +P.ResolveSpec = ResolveSpec + +-- Track which spec aura tables have already been sanitized this session +local sanitizedSpecAuras = {} + +-- Returns the spec-scoped auras sub-table, creating it if needed +-- Also sanitizes corrupted entries (non-table values like stray nextIndicatorID) +local function GetSpecAuras(spec) + local adDB = GetAuraDesignerDB() + if not adDB then return {} end + if not adDB.auras then adDB.auras = {} end + spec = spec or ResolveSpec() + if not spec then return {} end + if not adDB.auras[spec] then adDB.auras[spec] = {} end + local specAuras = adDB.auras[spec] + -- One-time cleanup: remove non-table entries that ended up at the wrong level + if not sanitizedSpecAuras[specAuras] then + local toRemove + for k, v in pairs(specAuras) do + if type(v) ~= "table" then + if not toRemove then toRemove = {} end + toRemove[#toRemove + 1] = k + end + end + if toRemove then + for _, k in ipairs(toRemove) do + specAuras[k] = nil + end + DF:DebugWarn("AD", "Cleaned %d corrupted entries from spec auras table", #toRemove) + end + sanitizedSpecAuras[specAuras] = true + end + return specAuras +end +P.GetSpecAuras = GetSpecAuras + +-- Returns the spec-INDEPENDENT "Other Buffs" pool (B1): a flat map auraName -> auraCfg +-- with the EXACT record shape of adDB.auras[spec] entries (indicators array, frame-level +-- sub-tables, sound, priority, nextIndicatorID), created + sanitized like GetSpecAuras. +-- Deliberately a SIBLING key of adDB.auras (never a pseudo-spec inside it) so every +-- adDB.auras consumer stays spec-scoped; a future per-spec expansion of this pool would +-- land under its own sibling key, so this flat map never needs a migration. Aura names +-- here must be SpellDB names (rec.n / localized) or ad-hoc "#" keys — identity +-- resolves spec-independently via DF:BuildADIdentityFilters(nil, name). +-- (B2 wires the Other Buffs tab/editor to this accessor; the factory reads +-- adDB.otherAuras directly, mirroring how it reads adDB.auras[spec].) +local function GetOtherAuras() + local adDB = GetAuraDesignerDB() + if not adDB then return {} end + if not adDB.otherAuras then adDB.otherAuras = {} end + local otherAuras = adDB.otherAuras + -- One-time cleanup, same as GetSpecAuras (registry is keyed by table identity, so + -- profile/preset switches re-sanitize the new table naturally). + if not sanitizedSpecAuras[otherAuras] then + local toRemove + for k, v in pairs(otherAuras) do + if type(v) ~= "table" then + if not toRemove then toRemove = {} end + toRemove[#toRemove + 1] = k + end + end + if toRemove then + for _, k in ipairs(toRemove) do + otherAuras[k] = nil + end + DF:DebugWarn("AD", "Cleaned %d corrupted entries from other auras table", #toRemove) + end + sanitizedSpecAuras[otherAuras] = true + end + return otherAuras +end +P.GetOtherAuras = GetOtherAuras + +-- Returns the preset's DEBUFF CATEGORY GROUPS array (C1): a flat, spec-INDEPENDENT +-- array of group records (mirror of the otherAuras siblings-not-pseudo-spec rule — +-- a future per-spec expansion would land under its own key, so this array never +-- needs a migration). Lazily created on first WRITE access only — merely opening +-- the editor must not create adDB.debuffGroups (mirror GetOtherAuras). Each record: +-- { id, name, enabled, anchor, offsetX, offsetY, growDirection, iconsPerRow, +-- spacing, iconSize, maxIcons, selection = { boss, role, priority, crowdControl, +-- raid, dispellable, dispellableMode, hideLong, hideLongMinutes, keepImportant } }. +-- The factory reads adDB.debuffGroups directly (as it reads otherAuras); the +-- C2 group editor reaches it via CreateDebuffGroup / DebuffGroupsRead. +local function GetDebuffGroups() + local adDB = GetAuraDesignerDB() + if not adDB then return {} end + if not adDB.debuffGroups then adDB.debuffGroups = {} end + local groups = adDB.debuffGroups + -- One-time cleanup, same table-identity registry as GetSpecAuras/GetOtherAuras + -- (profile/preset switches swap the table and re-sanitize naturally). + if not sanitizedSpecAuras[groups] then + for i = #groups, 1, -1 do + if type(groups[i]) ~= "table" then + tremove(groups, i) + DF:DebugWarn("AD", "Removed corrupted debuff group entry at index %d", i) + end + end + sanitizedSpecAuras[groups] = true + end + return groups +end + +-- Default DISPLAY NAME for a new group: one above the HIGHEST number currently in +-- use for this prefix. +-- +-- The id counters (nextLayoutGroupID / nextOtherLayoutGroupID / nextDebuffGroupID) +-- stay strictly monotonic on purpose — ids are stable references (indicator -> +-- group links), so reusing one would rebind stale links to the wrong group. The +-- visible LABEL has no such constraint, and deriving it from the raw id made it +-- climb forever ("Group 7" on an empty designer after six create/deletes). +-- +-- Why highest+1 and NOT the lowest free number: the list renders in CREATION order +-- (every site iterates ipairs(groups); nothing sorts them) and new groups are +-- tinsert-APPENDED. Filling a gap would therefore drop a low number at the BOTTOM +-- of the list — "Group 2" above "Group 1" — which reads worse than a gap. A gap is +-- also honest: it says something was deleted. Because this scans the CURRENT set +-- rather than a persisted counter, an emptied designer still restarts at 1. +-- +-- Prefixes are distinct and anchored, so "Group" won't match "Filter Group 3" (or +-- vice versa); none contain Lua pattern magic characters. A user-renamed group drops +-- out of the numbering pool. +local function NextGroupName(groups, prefix) + local highest = 0 + for _, g in ipairs(groups or {}) do + local n = tonumber(tostring((g and g.name) or ""):match("^" .. prefix .. " (%d+)$")) + if n and n > highest then highest = n end + end + return prefix .. " " .. (highest + 1) +end +P.NextGroupName = NextGroupName + +-- Create a new debuff category group (C1 data model; C2 wires the UI). Defaults: +-- Boss + Role selected (the classic "important debuffs" baseline), everything +-- else off, Hide Long staged at 5 minutes with Keep Important on. Layout mirrors +-- the filter-group creation defaults (compact 4x4). +local function CreateDebuffGroup(name) + local adDB = GetAuraDesignerDB() + if not adDB then return nil end + local groups = GetDebuffGroups() + if not adDB.nextDebuffGroupID then adDB.nextDebuffGroupID = 1 end + local id = adDB.nextDebuffGroupID + adDB.nextDebuffGroupID = id + 1 + local group = { + id = id, + name = name or NextGroupName(groups, "Debuff Group"), + anchor = "TOPLEFT", + offsetX = 0, + offsetY = 0, + growDirection = "RIGHT_DOWN", + iconsPerRow = 4, + spacing = 2, + iconSize = 24, + maxIcons = 4, + selection = { + boss = true, role = true, priority = false, crowdControl = false, + raid = false, dispellable = false, dispellableMode = "PLAYER", + hideLong = false, hideLongMinutes = 5, keepImportant = true, + }, + } + tinsert(groups, group) + return group +end +P.CreateDebuffGroup = CreateDebuffGroup + +-- Returns the spec-scoped layout groups array, creating it if needed +local function GetSpecLayoutGroups(spec) + local adDB = GetAuraDesignerDB() + if not adDB then return {} end + if not adDB.layoutGroups then adDB.layoutGroups = {} end + spec = spec or ResolveSpec() + if not spec then return {} end + if not adDB.layoutGroups[spec] then adDB.layoutGroups[spec] = {} end + return adDB.layoutGroups[spec] +end +P.GetSpecLayoutGroups = GetSpecLayoutGroups + +-- ============================================================ +-- MAIN POOL TABS (B2) — My Buffs / Other Buffs +-- The tab strip above the workspace decides which aura pool the ENTIRE +-- editor operates on: "my" = the spec-scoped adDB.auras[spec] pool, +-- "other" = the spec-INDEPENDENT adDB.otherAuras pool (B1). Every editor +-- read routes through CurrentAuraPool(); spec-only surfaces (layout +-- groups, migrations, the spec dropdown itself) deliberately do not. +-- ============================================================ + +S.activeBuffTab = "my" -- "my" | "debuffs" | "other" + +local function IsOtherTab() + return S.activeBuffTab == "other" +end +P.IsOtherTab = IsOtherTab + +-- C2: the Debuffs tab hosts debuff CATEGORY groups (spec-independent, no +-- spell pool, no placed indicators). Its Effects sub-tab frosts, the spec +-- dropdown greys, and CurrentAuraPool reads empty. +local function IsDebuffTab() + return S.activeBuffTab == "debuffs" +end +P.IsDebuffTab = IsDebuffTab + +-- Read-only placeholder returned while the other pool doesn't exist yet. +-- Merely VISITING the Other Buffs tab must not create adDB.otherAuras — +-- only the first ADD does (via CurrentAuraPoolWrite). Never written. +local EMPTY_POOL = {} +P.EMPTY_POOL = EMPTY_POOL + +-- READ access to the active tab's pool. Never creates adDB.otherAuras. +-- `spec` is forwarded to GetSpecAuras on the My Buffs tab only. +local function CurrentAuraPool(spec) + if S.activeBuffTab == "other" then + local adDB = GetAuraDesignerDB() + if adDB and adDB.otherAuras then return GetOtherAuras() end + return EMPTY_POOL + end + -- The Debuffs tab has no aura pool: no placed indicators and no + -- frame-level effects, so every pool-routed surface (preview passes, + -- effect list) reads empty. Category groups live in adDB.debuffGroups. + if S.activeBuffTab == "debuffs" then return EMPTY_POOL end + return GetSpecAuras(spec) +end +P.CurrentAuraPool = CurrentAuraPool + +-- WRITE access: creates the pool table (the other pool is born lazily on +-- the first add — drag-drop, picker click, or add-by-ID). +local function CurrentAuraPoolWrite() + if S.activeBuffTab == "other" then return GetOtherAuras() end + return GetSpecAuras() +end + +-- B1 key-prefix contract: wherever an editor key embeds an aura's name, +-- the other-pool record embeds "other:" .. auraName in the name segment +-- (expandedCards "placed:other:#" / "frame::other:", +-- preview slot keys). The auraName itself never carries the prefix. +local function PoolKeyPrefix() + return (S.activeBuffTab == "other") and "other:" or "" +end +P.PoolKeyPrefix = PoolKeyPrefix + +-- READ access to the debuff category groups array (C2). Never creates +-- adDB.debuffGroups — merely visiting the Debuffs tab must not write +-- (the EMPTY_POOL rule above); the first "+ Debuff Group" add creates it +-- via CreateDebuffGroup → GetDebuffGroups. +local function DebuffGroupsRead() + local adDB = GetAuraDesignerDB() + if adDB and adDB.debuffGroups then return GetDebuffGroups() end + return EMPTY_POOL +end +P.DebuffGroupsRead = DebuffGroupsRead + +-- Other Buffs LAYOUT GROUPS: a flat, spec-INDEPENDENT array of group records +-- over the other pool (sibling of otherAuras/debuffGroups — never a pseudo-spec +-- inside adDB.layoutGroups, so no migration can ever touch it). Records are +-- shape-identical to the spec-keyed layoutGroups entries (member kind with +-- {auraName, indicatorID} members referencing OTHER-pool placed indicators, +-- and filter kind with filterSelection). Own id counter +-- (adDB.nextOtherLayoutGroupID — ids can collide with the spec counter's, so +-- every derived key carries a pool marker). One accessor for both access +-- modes (the file-scope 200-locals ceiling forbids the two-function +-- GetDebuffGroups/DebuffGroupsRead split): `create` follows the lazy-write +-- rule — read access returns EMPTY_POOL while the store doesn't exist +-- (visiting the tab must never create it; the first "+ Create/Filter Group" +-- add passes create=true). +local function GetOtherLayoutGroups(create) + local adDB = GetAuraDesignerDB() + if not adDB then return EMPTY_POOL end + if not adDB.otherLayoutGroups then + if not create then return EMPTY_POOL end + adDB.otherLayoutGroups = {} + end + local groups = adDB.otherLayoutGroups + -- One-time cleanup, same table-identity registry as GetDebuffGroups. + if not sanitizedSpecAuras[groups] then + for i = #groups, 1, -1 do + if type(groups[i]) ~= "table" then + tremove(groups, i) + DF:DebugWarn("AD", "Removed corrupted other layout group entry at index %d", i) + end + end + sanitizedSpecAuras[groups] = true + end + return groups +end +P.GetOtherLayoutGroups = GetOtherLayoutGroups + +-- Active tab's layout groups (READ — never creates the other store): the +-- flat other store on Other Buffs, the spec-keyed array on My Buffs. +-- (Debuffs never reaches these — its Layout Groups tab builds debuff +-- category groups instead.) +local function CurrentLayoutGroups() + if S.activeBuffTab == "other" then return GetOtherLayoutGroups(false) end + return GetSpecLayoutGroups() +end +P.CurrentLayoutGroups = CurrentLayoutGroups + +-- Display name for an OTHER-pool aura key: ad-hoc "#" resolves live, +-- SpellDB names resolve through GetSpellDisplay (localized), else the raw key. +local function OtherPoolDisplayName(auraName) + local id = type(auraName) == "string" and auraName:match("^#(%d+)$") + if id then + local live = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(tonumber(id)) + return live or auraName + end + local R = DF.FilterRegistry + local rec = R and R.GetSpellByName and R:GetSpellByName(auraName) + if rec then + local display = R:GetSpellDisplay(rec) + if display then return display end + end + return auraName +end +P.OtherPoolDisplayName = OtherPoolDisplayName + +-- Union of every spell ID tracked by one pool of an adDB (cross-tab +-- used-affordance). which = "my" walks ALL spec pools (the preset object is +-- one: a spell tracked for ANY spec would double-render for that spec when +-- also in the shared other pool); "other" walks the flat other pool with +-- nil-spec identity. Pure (no editor state) — exposed for the SDD harness. +local function PoolTrackedIDs(adDB, which) + local out = {} + if type(adDB) ~= "table" then return out end + local function addIDs(spec, auraName) + local f = DF:BuildADIdentityFilters(spec, auraName) + local map = f and f.includeSpellIDs + if map then + for id in pairs(map) do out[id] = true end + end + end + if which == "my" then + if type(adDB.auras) == "table" then + for specKey, specAuras in pairs(adDB.auras) do + if type(specAuras) == "table" then + for auraName, cfg in pairs(specAuras) do + if type(cfg) == "table" then addIDs(specKey, auraName) end + end + end + end + end + else -- "other" + if type(adDB.otherAuras) == "table" then + for auraName, cfg in pairs(adDB.otherAuras) do + if type(cfg) == "table" then addIDs(nil, auraName) end + end + end + end + return out +end +DF.ADEditor_PoolTrackedIDs = PoolTrackedIDs + +-- IDs tracked by the OPPOSITE pool of the active tab (drives the picker's +-- cross-tab blocked rows and the add-by-ID gate). +local function CrossPoolTrackedIDs() + return PoolTrackedIDs(GetAuraDesignerDB(), IsOtherTab() and "my" or "other") +end +P.CrossPoolTrackedIDs = CrossPoolTrackedIDs + +-- Ensure an aura config table exists, creating it with defaults if needed. +-- `pool` (optional) pins the target pool — proxies capture their pool at +-- creation so a stale color-picker callback can't write across tabs. +local function EnsureAuraConfig(auraName, pool) + local auras = pool or CurrentAuraPoolWrite() + if not auras[auraName] then + auras[auraName] = { + priority = 5, + } + end + return auras[auraName] +end +P.EnsureAuraConfig = EnsureAuraConfig + +-- Ensure a type sub-table exists within an aura config +local function EnsureTypeConfig(auraName, typeKey, pool) + local auraCfg = EnsureAuraConfig(auraName, pool) + if not auraCfg[typeKey] then + -- Read global defaults so new configs inherit user-configured values + local adDB = GetAuraDesignerDB() + local gd = adDB and adDB.defaults or {} + + -- Create default config for each type + if typeKey == "icon" then + auraCfg[typeKey] = { + -- Placement + anchor = "TOPLEFT", offsetX = 0, offsetY = 0, + -- Size & appearance (from global defaults) + size = gd.iconSize or 24, scale = gd.iconScale or 1.0, alpha = 1.0, + -- Border + -- Canonical border keys (Stage 5.1b/c). Legacy names were + -- borderEnabled / borderThickness / borderInset; migrated + -- via DF:MigrateAuraDesignerIconBorderKeys on ADDON_LOADED. + -- ShowBorder/BorderSize/BorderInset are stored on the + -- aura's icon sub-config; everything else (style, colour, + -- gradient, shadow, offset, blend) reads from TYPE_DEFAULTS + -- via proxy fall-through until the user overrides it. + -- Seed Show/Size from the global icon-border defaults so the + -- "Import Buffs Tab Defaults" border toggle actually carries over. + ShowBorder = (gd.iconBorderEnabled ~= false), BorderSize = gd.iconBorderThickness or 1, BorderInset = 0, + hideSwipe = false, + -- Duration text + showDuration = gd.showDuration ~= false, + durationFont = gd.durationFont or "DF Roboto SemiBold", + durationScale = gd.durationScale or 1.0, + durationOutline = gd.durationOutline or "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = true, -- DELIBERATE hardcode (v4 parity): new placements always start coloured, even in profiles whose global default is OFF — Krathe's call. Already-placed indicators are never touched. + -- Stack count + showStacks = gd.showStacks ~= false, + stackFont = gd.stackFont or "DF Roboto SemiBold", + stackScale = gd.stackScale or 1.0, + stackOutline = gd.stackOutline or "SHADOW;OUTLINE", + stackAnchor = "BOTTOMRIGHT", + stackX = 2, stackY = -2, + } + elseif typeKey == "square" then + auraCfg[typeKey] = { + -- Placement + anchor = "TOPLEFT", offsetX = 0, offsetY = 0, + -- Appearance (from global defaults) + size = gd.iconSize or 24, scale = gd.iconScale or 1.0, alpha = 1.0, + color = {r = 1, g = 1, b = 1, a = 1}, + -- Border (canonical keys, Stage 5.2; legacy migrated on load) + ShowBorder = true, BorderSize = 1, BorderInset = 0, + hideSwipe = false, + -- Duration text + showDuration = gd.showDuration ~= false, + durationFont = gd.durationFont or "DF Roboto SemiBold", + durationScale = gd.durationScale or 1.0, + durationOutline = gd.durationOutline or "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = true, -- DELIBERATE hardcode (v4 parity): new placements always start coloured, even in profiles whose global default is OFF — Krathe's call. Already-placed indicators are never touched. + -- Stack count + showStacks = gd.showStacks ~= false, + stackFont = gd.stackFont or "DF Roboto SemiBold", + stackScale = gd.stackScale or 1.0, + stackOutline = gd.stackOutline or "SHADOW;OUTLINE", + stackAnchor = "BOTTOMRIGHT", + stackX = 2, stackY = -2, + } + elseif typeKey == "bar" then + auraCfg[typeKey] = { + -- Placement + anchor = "BOTTOM", offsetX = 0, offsetY = 0, + -- Size & orientation + orientation = "HORIZONTAL", width = 60, height = 6, + matchFrameWidth = true, matchFrameHeight = false, + -- Texture & colors + texture = "Interface\\TargetingFrame\\UI-StatusBar", + fillColor = {r = 1, g = 1, b = 1, a = 1}, + bgColor = {r = 0, g = 0, b = 0, a = 0.5}, + -- Border (canonical keys, Stage 5.3; legacy migrated on load) + ShowBorder = true, BorderSize = 1, BorderInset = 0, + BorderColor = {r = 0, g = 0, b = 0, a = 1}, + -- Alpha + alpha = 1.0, + -- Bar color by time + barColorByTime = false, + -- Duration text + showDuration = true, + durationFont = gd.durationFont or "DF Roboto SemiBold", + durationScale = gd.durationScale or 1.0, + durationOutline = gd.durationOutline or "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = true, -- DELIBERATE hardcode (v4 parity): new placements always start coloured, even in profiles whose global default is OFF — Krathe's call. Already-placed indicators are never touched. + } + elseif typeKey == "border" then + auraCfg[typeKey] = { + -- Border (canonical keys, Stage 5.4; legacy style/thickness/ + -- inset/color migrated on load) + ShowBorder = true, BorderStyle = "SOLID", BorderSize = 2, BorderInset = 0, + BorderColor = {r = 1, g = 1, b = 1, a = 1}, + drawAboveFrameBorder = true, + showWhenMissing = false, + } + elseif typeKey == "healthbar" then + auraCfg[typeKey] = { + mode = "Replace", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, + tintWholeBar = false, + showWhenMissing = false, + } + elseif typeKey == "background" then + auraCfg[typeKey] = { + mode = "Tint", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, + showWhenMissing = false, + } + elseif typeKey == "nametext" then + auraCfg[typeKey] = { + color = {r = 1, g = 1, b = 1, a = 1}, + showWhenMissing = false, + } + elseif typeKey == "healthtext" then + auraCfg[typeKey] = { + color = {r = 1, g = 1, b = 1, a = 1}, + showWhenMissing = false, + } + elseif typeKey == "sound" then + auraCfg[typeKey] = { + enabled = false, + soundFile = nil, + soundLSMKey = nil, + volume = 0.8, + -- Per-event native sounds (12.1 AddAuraSound triggers). The flat sound + -- above is the APPLIED sound (Added trigger). dropped = Removed (buff + -- fell off), stackGained = ApplicationsIncreased (stack gained). There is + -- no stacks-lost (no ApplicationsDecreased trigger). Distinct from the + -- blocked Missing/Expire alerts, which need sealed presence/remaining-time. + appliedEnabled = true, + dropped = { enabled = false, soundLSMKey = nil, soundFile = nil }, + stackGained = { enabled = false, soundLSMKey = nil, soundFile = nil }, + } + end + end + return auraCfg[typeKey] +end +P.EnsureTypeConfig = EnsureTypeConfig + +-- Default values per type key, used as fallback when a saved config is missing new keys +local TYPE_DEFAULTS = { + icon = { + anchor = "TOPLEFT", offsetX = 0, offsetY = 0, + size = 24, scale = 1.0, alpha = 1.0, + -- Canonical border keys (Stage 5.1b/c). Legacy borderEnabled / + -- borderThickness / borderInset migrated on ADDON_LOADED via + -- DF:MigrateAuraDesignerIconBorderKeys. BorderColor defaults to + -- the pre-migration hardcoded translucent black so existing users + -- see no visual change. Style / Gradient* / Shadow* defaults seed + -- CreateBorderControls' dropdowns and pickers so they read sensible + -- values on first open. + ShowBorder = true, BorderSize = 1, BorderInset = 0, + BorderColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderOffsetX = 0, + BorderOffsetY = 0, + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + -- Animation defaults match Frame Border's Stage 3 defaults so the + -- behaviour of "pick PULSATE" reads the same across the addon. + -- BorderAnimationType = "NONE" means no continuous animation; the + -- spec.animation block is omitted by BuildSpec so Apply doesn't + -- start anything. Picking a non-NONE type surfaces the relevant + -- tunables (helper handles hide/show per effect). + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + -- 1 Hz default ≈ 1-second cycle, matching the legacy AD Pulsate + -- Border pulse rate. Frame Border / Defensive Icon use 0.25 which + -- reads as a slow gentle pulse at full-frame scale; at icon scale + -- (24px) the same rate looks like a static dim border because the + -- transitions are too gradual to perceive. + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, + hideSwipe = false, hideIcon = false, + showDuration = true, durationFormat = "NUMBER", durationFont = "DF Roboto SemiBold", + durationScale = 1.2, durationOutline = "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = true, + durationColor = {r = 1, g = 1, b = 1, a = 1}, + durationHideAboveEnabled = false, durationHideAboveThreshold = 10, + durationHideOnPermanent = true, -- Wave 4: no timer text on permanent auras + expiryAlertEnabled = false, expiryAlertMode = "BORDER", expiryAlertThreshold = 5, expiryAlertThresholdPercent = 30, expiryAlertThresholdUnit = "SECONDS", + expiryAlertText = "", expiryAlertGlyph = "WARNING", + expiryAlertAnchor = "TOP", expiryAlertOffsetX = 0, expiryAlertOffsetY = 0, + expiryAlertSize = 14, + -- Expiry Alert BORDER mode (secret-safe expiring frame): colour + auto-match + inset. + expiryAlertBorderMatchIcon = true, expiryAlertBorderInset = 0, + expiryAlertBorderColorMode = "STATIC", expiryAlertBorderThickness = "MEDIUM", + expiryAlertBorderAlpha = 1, + expiryAlertBorderColor = {r = 1, g = 0.2, b = 0.2, a = 1}, + showStacks = true, + stackFont = "DF Roboto SemiBold", stackScale = 1.0, + stackOutline = "SHADOW;OUTLINE", stackAnchor = "BOTTOMRIGHT", + stackX = 2, stackY = -2, + stackColor = {r = 1, g = 1, b = 1, a = 1}, + -- Expiring Tint overlay (secret-safe). Default OFF, red — also feeds the + -- colour picker's Default button via the proxy's __dfDefaults = TYPE_DEFAULTS. + -- #FF3333 @ 50% (matches expiring border red) + -- legacy; migrated to ExpiringAnimationType + -- Master enable for the whole Expiring feature. Default true so + -- existing configs are unaffected; turning it OFF disables every + -- expiring override (colour / thickness / alpha / animation / pulse / + -- bounce) regardless of their individual settings, and hides the rest + -- of the Expiring panel. + -- Stage 5.1d.2 + parity: full Border Animation effect set as the value + -- the expiring callback swaps into spec.animation when remaining < + -- threshold. NONE = no animation override. The expiring animation + -- carries its OWN complete tunable set (colour, particles, thickness, + -- offset, …) independent of the base Border Animation — mirrors the + -- base defaults so the two panels read identically. + -- Stage 5.1d.3: per-state thickness + alpha overrides. Default to + -- 1 / 1 — same thickness as the base (1) and slightly more opaque + -- than the base alpha (0.8), so out of the box a user enabling + -- Expiring Color Override sees the border tick to fully opaque red + -- below threshold (subtle "more solid" feel). Move the sliders + -- higher / lower for stronger emphasis. Only take effect when the + -- expiring ticker is running (i.e. user has at least one expiring + -- feature on — colour override, animation, alpha pulse, or bounce). + -- Duration bar strip (mirrors the buff/debuff rows + filter-group cards): + -- a native SetDurationBar-driven strip under/over the icon. OFF by default; + -- render fallbacks live in DF:BuildDurationBarSpec — these seed the editor. + durationBarEnabled = false, durationBarPosition = "BOTTOM", + durationBarHeight = 4, durationBarGap = 1, durationBarColorMode = "STATIC", + durationBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", + durationBarColor = {r = 0.2, g = 0.9, b = 0.3, a = 1}, + durationBarBGColor = {r = 0, g = 0, b = 0, a = 0.8}, + durationBarReverseFill = false, + frameLevel = 40, frameStrata = "INHERIT", + showWhenMissing = false, missingDesaturate = false, + }, + square = { + anchor = "TOPLEFT", offsetX = 0, offsetY = 0, + size = 24, scale = 1.0, alpha = 1.0, + color = {r = 1, g = 1, b = 1, a = 1}, + -- Canonical border keys (Stage 5.2). Legacy showBorder / + -- borderThickness / borderInset migrated on ADDON_LOADED. BorderColor + -- defaults to opaque black, matching the square's pre-migration + -- hardcoded border so existing users see no change. The rest seed + -- CreateBorderControls' dropdowns / pickers on first open. + ShowBorder = true, BorderSize = 1, BorderInset = 0, + BorderColor = {r = 0, g = 0, b = 0, a = 1}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderOffsetX = 0, + BorderOffsetY = 0, + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, + hideSwipe = false, hideIcon = false, + showDuration = true, durationFormat = "NUMBER", durationFont = "DF Roboto SemiBold", + durationScale = 1.2, durationOutline = "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = true, + durationColor = {r = 1, g = 1, b = 1, a = 1}, + durationHideAboveEnabled = false, durationHideAboveThreshold = 10, + durationHideOnPermanent = true, -- Wave 4: no timer text on permanent auras + expiryAlertEnabled = false, expiryAlertMode = "BORDER", expiryAlertThreshold = 5, expiryAlertThresholdPercent = 30, expiryAlertThresholdUnit = "SECONDS", + expiryAlertText = "", expiryAlertGlyph = "WARNING", + expiryAlertAnchor = "TOP", expiryAlertOffsetX = 0, expiryAlertOffsetY = 0, + expiryAlertSize = 14, + -- Expiry Alert BORDER mode (secret-safe expiring frame): colour + auto-match + inset. + expiryAlertBorderMatchIcon = true, expiryAlertBorderInset = 0, + expiryAlertBorderColorMode = "STATIC", expiryAlertBorderThickness = "MEDIUM", + expiryAlertBorderAlpha = 1, + expiryAlertBorderColor = {r = 1, g = 0.2, b = 0.2, a = 1}, + showStacks = true, + stackFont = "DF Roboto SemiBold", stackScale = 1.0, + stackOutline = "SHADOW;OUTLINE", stackAnchor = "BOTTOMRIGHT", + stackX = 2, stackY = -2, + stackColor = {r = 1, g = 1, b = 1, a = 1}, + -- Master enable for the whole Expiring feature (Stage 5.2 — mirrors + -- the icon). Default true so existing configs are unaffected. + -- #FF3333 @ 50% (matches expiring border red) + -- Stage 5.2 expiring-border overrides (shared backend with the icon). + -- ExpiringBorderColor is SEPARATE from the fill's expiringColor — the + -- fill and border each get their own expiring tint. Defaults to the + -- same red so out of the box both "turn red", but they're independent. + -- Duration bar strip (mirrors the icon card): a native SetDurationBar-driven + -- strip under/over the square. OFF by default; render fallbacks live in + -- DF:BuildDurationBarSpec — these seed the editor. + durationBarEnabled = false, durationBarPosition = "BOTTOM", + durationBarHeight = 4, durationBarGap = 1, durationBarColorMode = "STATIC", + durationBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", + durationBarColor = {r = 0.2, g = 0.9, b = 0.3, a = 1}, + durationBarBGColor = {r = 0, g = 0, b = 0, a = 0.8}, + durationBarReverseFill = false, + frameLevel = 40, frameStrata = "INHERIT", + showWhenMissing = false, + }, + bar = { + anchor = "BOTTOM", offsetX = 0, offsetY = 0, + orientation = "HORIZONTAL", width = 60, height = 6, + matchFrameWidth = true, matchFrameHeight = false, + barColorMode = "STATIC", -- STATIC / DF / DFSTOPS / CLASSIC (curve = green->red ramp as it drains) + texture = "Interface\\TargetingFrame\\UI-StatusBar", + fillColor = {r = 1, g = 1, b = 1, a = 1}, + bgColor = {r = 0, g = 0, b = 0, a = 0.5}, + -- Canonical border keys (Stage 5.3). Legacy showBorder / + -- borderThickness / borderColor migrated on ADDON_LOADED. BorderInset + -- defaults to 0 so the ring sits FLUSH outside the bar as before. + -- BorderColor defaults to opaque black (the bar's pre-migration look). + ShowBorder = true, BorderSize = 1, BorderInset = 0, + BorderColor = {r = 0, g = 0, b = 0, a = 1}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, + alpha = 1.0, + barColorByTime = false, + -- #FF3333 @ 50% (matches expiring border red) + -- FALSE to match the factory's bar render default (buildDurationTextSpec is + -- called with defaultShow = false for bars — no text out of the box). It was + -- `true` here, so a never-toggled bar showed a TICKED checkbox while rendering + -- no text — and durationFmtKey's early-return ("") meant Duration Format + -- changes never moved the struct sig, reading as "stale until I toggle + -- Show Duration" (Krathe, 2026-07-24). Instances are created SPARSE + -- (CreateIndicatorInstance), so this fallback IS the checkbox for new bars. + showDuration = false, durationFormat = "NUMBER", durationFont = "DF Roboto SemiBold", + durationScale = 1.2, durationOutline = "SHADOW;OUTLINE", + durationAnchor = "CENTER", durationX = 0, durationY = 0, + durationColorByTime = true, + durationHideAboveEnabled = false, durationHideAboveThreshold = 10, + durationHideOnPermanent = true, -- Wave 4: no timer text on permanent auras + -- A bar's expiry COLOUR is its own fill (the Duration Bar Color Mode reddens as it + -- drains); the |T reveal only offers Text / Glyph here, so default to a warning Glyph. + expiryAlertEnabled = false, expiryAlertMode = "GLYPH", expiryAlertThreshold = 5, expiryAlertThresholdPercent = 30, expiryAlertThresholdUnit = "SECONDS", + expiryAlertText = "", expiryAlertGlyph = "WARNING", + expiryAlertAnchor = "TOP", expiryAlertOffsetX = 0, expiryAlertOffsetY = 0, + expiryAlertSize = 14, + frameLevel = 40, frameStrata = "INHERIT", + }, + -- Frame-level types: mirror the inline literals in EnsureTypeConfig so the + -- colour-picker Default button (and any other consumer of __dfDefaults) can + -- resolve a default value for keys like "color" and "expiringColor". + -- Border-type (Stage 5.4): full canonical DF.Border defaults so + -- CreateBorderControls' dropdowns / pickers read sensible values. The + -- legacy style/thickness/inset/color are migrated on load. + border = { + ShowBorder = true, BorderSize = 2, BorderInset = 0, + BorderColor = {r = 1, g = 1, b = 1, a = 1}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderOffsetX = 0, + BorderOffsetY = 0, + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, + -- Draw above the frame's class border (parent+10) / aggro (parent+9). + drawAboveFrameBorder = true, + -- Expiring-border overrides (Stage 5.4 parity with icon/square). + showWhenMissing = false, + }, + healthbar = { + mode = "Replace", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, + tintWholeBar = false, + showWhenMissing = false, + }, + background = { + mode = "Tint", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, + showWhenMissing = false, + }, + nametext = { + color = {r = 1, g = 1, b = 1, a = 1}, + showWhenMissing = false, + }, + healthtext = { + color = {r = 1, g = 1, b = 1, a = 1}, + showWhenMissing = false, + }, +} \ No newline at end of file diff --git a/DandersFrames.toc b/DandersFrames.toc index 9a619c94..395837fe 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -130,7 +130,13 @@ AuraDesigner\AuraAdapter.lua AuraDesigner\Engine.lua AuraDesigner\Factory.lua AuraDesigner\SoundEngine.lua +# Editor UI, split from one 9k-line file. Order is load-bearing: Options.lua +# creates DF.AuraDesigner._uiState / ._priv, which every later part aliases. AuraDesigner\UI\Options.lua +AuraDesigner\UI\Groups.lua +AuraDesigner\UI\Indicators.lua +AuraDesigner\UI\Cards.lua +AuraDesigner\UI\Editor.lua # Text Designer TextDesigner\TextDesigner.lua From b31f4ad4a6d555cb4273357a4eab2054fbf5d46e Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 18:17:40 +0100 Subject: [PATCH 08/34] Fix nil CreateElementBackdrop after the GUI.lua split Fixes: GUI/Widgets.lua:773 attempt to call a nil value, via PinnedFrames.lua CreateSetFrames -> Initialize at login. My alias rule was wrong. It treated `local X` and `GUI.X` as the same object whenever GUI.X appeared anywhere, so parts 2-5 got `local CreateElementBackdrop = GUI.CreateElementBackdrop`. But those two public names are METHOD WRAPPERS declared in Widgets.lua: function GUI:CreateElementBackdrop(frame, opts) return CreateElementBackdrop(frame, opts) -- the file-scope local end They are a different object that happens to share the name, and they are declared AFTER the preamble runs -- so the alias was nil at load. Had it resolved, the wrapper would have called itself. Both real locals are now published on GUI._priv beside the others, and all four aliases point there. The public wrappers are unchanged and still work for outside callers like PinnedFrames. An audit of all 10 split files found exactly these two; the other 8 aliases of the form `local X = GUI.X` are backed by a real `GUI.X = X` identity assignment and are correct. The GUI split's byte-identity proof held for the commit that made it; this fix intentionally adds 8 lines to part 1, so that manifest entry is now marked historical rather than left as a failing check. --- GUI/Controls.lua | 2 +- GUI/GUI.lua | 8 ++++++++ GUI/Panel.lua | 4 ++-- GUI/Sections.lua | 2 +- GUI/Widgets.lua | 4 ++-- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/GUI/Controls.lua b/GUI/Controls.lua index 43beb6cd..2906631d 100644 --- a/GUI/Controls.lua +++ b/GUI/Controls.lua @@ -10,7 +10,7 @@ local C_PANEL, C_ELEMENT, C_HOVER, C_TEXT, C_TEXT_DIM = local CloseOpenDropdown = GUI._priv.CloseOpenDropdown local GetThemeColor = GUI.GetThemeColor local SnapLen = GUI.SnapLen -local CreateElementBackdrop = GUI.CreateElementBackdrop +local CreateElementBackdrop = GUI._priv.CreateElementBackdrop local StyleScrollBar = GUI.StyleScrollBar local AddOverrideIndicators = GUI._priv.AddOverrideIndicators local AddOrderListOverrideIndicators = GUI._priv.AddOrderListOverrideIndicators diff --git a/GUI/GUI.lua b/GUI/GUI.lua index f720926e..f9b55c9b 100644 --- a/GUI/GUI.lua +++ b/GUI/GUI.lua @@ -1654,6 +1654,14 @@ local function CreatePanelBackdrop(frame) borderColor = { 0, 0, 0, 1 }, }) end +-- ☠ These two MUST be published here, on the private table, not aliased from +-- GUI.CreateElementBackdrop / GUI.CreatePanelBackdrop. Those public names are +-- METHOD WRAPPERS (`function GUI:X(...) return X(...) end`) declared later in +-- GUI/Widgets.lua -- a different object that happens to share the name. A +-- sibling part aliasing the wrapper gets nil at load time, and if it did not, +-- the wrapper would end up calling itself. +P.CreateElementBackdrop = CreateElementBackdrop +P.CreatePanelBackdrop = CreatePanelBackdrop -- Style a ScrollFrameTemplate scrollbar to use the pill-shaped thumb -- All scroll frames must use ScrollFrameTemplate (not UIPanelScrollFrameTemplate) diff --git a/GUI/Panel.lua b/GUI/Panel.lua index 2dc31d20..efa70728 100644 --- a/GUI/Panel.lua +++ b/GUI/Panel.lua @@ -11,8 +11,8 @@ local ResolveRowHeight = GUI.ResolveRowHeight local GetThemeColor = GUI.GetThemeColor local SnapLen = GUI.SnapLen local SnapLenUp = GUI.SnapLenUp -local CreateElementBackdrop = GUI.CreateElementBackdrop -local CreatePanelBackdrop = GUI.CreatePanelBackdrop +local CreateElementBackdrop = GUI._priv.CreateElementBackdrop +local CreatePanelBackdrop = GUI._priv.CreatePanelBackdrop local StyleScrollBar = GUI.StyleScrollBar local RefreshAllOverrideIndicators = GUI.RefreshAllOverrideIndicators function DF:CreateGUI() diff --git a/GUI/Sections.lua b/GUI/Sections.lua index 67410167..2339fd6d 100644 --- a/GUI/Sections.lua +++ b/GUI/Sections.lua @@ -11,7 +11,7 @@ local C_BACKGROUND, C_TEXT, C_TEXT_DIM = local ResolveRowHeight = GUI.ResolveRowHeight local GetThemeColor = GUI.GetThemeColor local SnapLen = GUI.SnapLen -local CreateElementBackdrop = GUI.CreateElementBackdrop +local CreateElementBackdrop = GUI._priv.CreateElementBackdrop function GUI:CreateSettingsGroup(parent, width, opts) -- opts can be a boolean (legacy: collapsible) or a table { collapsible, showSummary, onCollapseChanged } diff --git a/GUI/Widgets.lua b/GUI/Widgets.lua index e143ddef..8f9664e6 100644 --- a/GUI/Widgets.lua +++ b/GUI/Widgets.lua @@ -13,8 +13,8 @@ local GetThemeColorFor = GUI.GetThemeColorFor local GetThemeColor = GUI.GetThemeColor local SnapLen = GUI.SnapLen local SnapHeightEven = GUI.SnapHeightEven -local CreateElementBackdrop = GUI.CreateElementBackdrop -local CreatePanelBackdrop = GUI.CreatePanelBackdrop +local CreateElementBackdrop = GUI._priv.CreateElementBackdrop +local CreatePanelBackdrop = GUI._priv.CreatePanelBackdrop local StyleScrollBar = GUI.StyleScrollBar local INFO_BANNER_TONES = GUI._priv.INFO_BANNER_TONES From fcdff72a6d14fd00e86e4c39e5b20d270d3cbc76 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 18:24:06 +0100 Subject: [PATCH 09/34] Fix nil TYPE_DEFAULTS after the Aura Designer split Fixes: Groups.lua:259 attempt to index a nil value, via CreateEffectCard -> BuildEffectsTab -> SwitchTab when opening the Effects tab. The publish pass inserted each `P.X = X` immediately after its definition's last line. For three symbols that last line sat right against a split boundary, so the publish landed in the NEXT part -- which then did: local TYPE_DEFAULTS = P.TYPE_DEFAULTS -- nil, nothing published yet P.TYPE_DEFAULTS = TYPE_DEFAULTS -- publishes nil Affected: TYPE_DEFAULTS (Groups -> Options), BuildTypeContent (Cards -> Indicators), AddGroupAppearanceSection (Editor -> Cards). Each publish now sits at the end of the part that defines the symbol. A publish must never be separated from its definition by a cut. That is now enforced by docs/reorg-tools/alias_check.py, which also covers the wrapper alias that caused the previous crash. Both faults are invisible to parsing, the TOC check, load-order and the concat proof -- they only surface at runtime as a nil value -- so the checker was verified by running it against the two pre-fix commits and confirming it reports all six. --- AuraDesigner/UI/Cards.lua | 6 ++++-- AuraDesigner/UI/Editor.lua | 1 - AuraDesigner/UI/Groups.lua | 1 - AuraDesigner/UI/Indicators.lua | 5 ++++- AuraDesigner/UI/Options.lua | 28 +++++++++++++++++----------- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/AuraDesigner/UI/Cards.lua b/AuraDesigner/UI/Cards.lua index 98c763b6..0a8a858b 100644 --- a/AuraDesigner/UI/Cards.lua +++ b/AuraDesigner/UI/Cards.lua @@ -56,7 +56,6 @@ local dragState = P.dragState local RefreshPlacedIndicators = P.RefreshPlacedIndicators local RefreshPreviewEffects = P.RefreshPreviewEffects local BuildTypeContent = P.BuildTypeContent -P.BuildTypeContent = BuildTypeContent -- ============================================================ -- GLOBAL VIEW (used by Global tab) @@ -2460,4 +2459,7 @@ local function AddGroupAppearanceSection(body, group, bodyWidth, by, cardKey) end) return by -end \ No newline at end of file +end + +-- ☠ Published HERE, in the part that DEFINES it -- see the note in Options.lua. +P.AddGroupAppearanceSection = AddGroupAppearanceSection \ No newline at end of file diff --git a/AuraDesigner/UI/Editor.lua b/AuraDesigner/UI/Editor.lua index f56d798d..04753a14 100644 --- a/AuraDesigner/UI/Editor.lua +++ b/AuraDesigner/UI/Editor.lua @@ -51,7 +51,6 @@ local UpdateSpecDropdownState = P.UpdateSpecDropdownState local SetMainTab = P.SetMainTab local OpenGroupSpellPicker = P.OpenGroupSpellPicker local AddGroupAppearanceSection = P.AddGroupAppearanceSection -P.AddGroupAppearanceSection = AddGroupAppearanceSection S.BuildLayoutGroupsTab = function() if not S.tabContentFrame then return end diff --git a/AuraDesigner/UI/Groups.lua b/AuraDesigner/UI/Groups.lua index 158e9f90..50b0ac87 100644 --- a/AuraDesigner/UI/Groups.lua +++ b/AuraDesigner/UI/Groups.lua @@ -27,7 +27,6 @@ local OtherPoolDisplayName = P.OtherPoolDisplayName local EnsureAuraConfig = P.EnsureAuraConfig local EnsureTypeConfig = P.EnsureTypeConfig local TYPE_DEFAULTS = P.TYPE_DEFAULTS -P.TYPE_DEFAULTS = TYPE_DEFAULTS -- ============================================================ -- INSTANCE-BASED INDICATOR HELPERS diff --git a/AuraDesigner/UI/Indicators.lua b/AuraDesigner/UI/Indicators.lua index a5e2d980..68403e57 100644 --- a/AuraDesigner/UI/Indicators.lua +++ b/AuraDesigner/UI/Indicators.lua @@ -1038,4 +1038,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff totalHeight = totalHeight + 8 -- bottom padding parent:SetHeight(totalHeight) return widgets, totalHeight -end \ No newline at end of file +end + +-- ☠ Published HERE, in the part that DEFINES it -- see the note in Options.lua. +P.BuildTypeContent = BuildTypeContent \ No newline at end of file diff --git a/AuraDesigner/UI/Options.lua b/AuraDesigner/UI/Options.lua index 0b1b2d6b..2aee7d2c 100644 --- a/AuraDesigner/UI/Options.lua +++ b/AuraDesigner/UI/Options.lua @@ -26,18 +26,18 @@ local L = DF.L -- state via `local S = DF.AuraDesigner._uiState`. -- GUI and Adapter are NOT here: both are load-time constants (DF.GUI and -- DF.AuraDesigner.Adapter), so each part can simply re-declare them. --- Shared private helpers. --- A `local function` is invisible to any other file, so a helper called far --- from its declaration pins this file together -- it cannot be split while a --- later section still calls it. This editor is densely coupled: 75 helpers --- reach across the chosen boundaries. Publishing them lets each part --- re-declare `local X = P.X` -- the same function object, so no call site --- changes and behaviour is identical. --- Private by convention -- nothing outside AuraDesigner/ should touch this. -local P = {} +-- Shared private helpers. +-- A `local function` is invisible to any other file, so a helper called far +-- from its declaration pins this file together -- it cannot be split while a +-- later section still calls it. This editor is densely coupled: 75 helpers +-- reach across the chosen boundaries. Publishing them lets each part +-- re-declare `local X = P.X` -- the same function object, so no call site +-- changes and behaviour is identical. +-- Private by convention -- nothing outside AuraDesigner/ should touch this. +local P = {} local S = {} DF.AuraDesigner = DF.AuraDesigner or {} -DF.AuraDesigner._uiState = S +DF.AuraDesigner._uiState = S DF.AuraDesigner._priv = P -- Load-time constants. Both used to be assigned inside BuildAuraDesignerPage, @@ -1735,4 +1735,10 @@ local TYPE_DEFAULTS = { color = {r = 1, g = 1, b = 1, a = 1}, showWhenMissing = false, }, -} \ No newline at end of file +} + +-- ☠ Published HERE, in the part that DEFINES it. A publish must never be +-- separated from its definition by a split boundary: this one originally +-- landed in the next part, which then aliased P.TYPE_DEFAULTS before anything +-- had published it -- nil, and a crash on the first effect card. +P.TYPE_DEFAULTS = TYPE_DEFAULTS \ No newline at end of file From d5ce61f1ad07aa5aae75f9f3db512c49133d8d42 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 18:28:28 +0100 Subject: [PATCH 10/34] Split the settings pages (9,681 lines) into five files DF:SetupGUIPages was a single 9,673-line function -- the largest remaining monolith and the last of the three Krathe asked to split. Options.lua 2,010 general, profiles, the shared page helpers Frames.lua 1,896 Auras.lua 1,754 Indicators.lua 1,918 Modules.lua 2,131 Not a pure move: each part gains a function header, a hand-off call and a closing `end`. Everything between them is verbatim, and all 28 added lines are declared in docs/reorg-tools/splits.manifest, so split_verify still proves the body byte-for-byte against the parent commit. The parts run as a CHAIN -- part 1 calls part 2 at its end, part 2 calls part 3, and so on. That keeps the pages building in their original order and keeps every added line at a part's start or end, which is the only place a strip can reach. Six locals crossing a boundary are threaded as parameters: L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons. The two crashes from the earlier splits were both a part unable to SEE a name it used, and neither was visible to any static check. So the splitter now refuses to write anything unless every name a part references is either threaded as a parameter or re-declared in its preamble. That assertion immediately caught one: `format` is a file-scope local used in three of the five parts, and would have been nil in all three. split_verify's `tail:` directive, needed for the trailing hand-off, is covered by docs/reorg-tools/test_tail_strip.py -- six cases including interior-`end` survival and a round-trip concat. Three earlier attempts at it each produced a different wrong answer against the real file, so this time it was tested against synthetic cases before being trusted. --- DandersFrames.toc | 8 + GUI/Pages/Auras.lua | 1754 ++++++ GUI/Pages/Frames.lua | 1896 ++++++ GUI/Pages/Indicators.lua | 1918 ++++++ GUI/Pages/Modules.lua | 2130 +++++++ GUI/Pages/Options.lua | 11690 +++++++------------------------------ 6 files changed, 9716 insertions(+), 9680 deletions(-) create mode 100644 GUI/Pages/Auras.lua create mode 100644 GUI/Pages/Frames.lua create mode 100644 GUI/Pages/Indicators.lua create mode 100644 GUI/Pages/Modules.lua diff --git a/DandersFrames.toc b/DandersFrames.toc index 395837fe..16a421c4 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -83,7 +83,15 @@ GUI\Panel.lua GUI\DFFonts.lua GUI\ColorPicker.lua Features\Search.lua +# Settings pages. DF:SetupGUIPages was one 9,673-line function; it is now a +# chain -- Options.lua calls part 2 at its end, part 2 calls part 3, and so on. +# Order is load-bearing: a later part must be defined before the earlier one +# runs, and all five must load before anything opens the panel. GUI\Pages\Options.lua +GUI\Pages\Frames.lua +GUI\Pages\Auras.lua +GUI\Pages\Indicators.lua +GUI\Pages\Modules.lua GUI\Pages\AutoProfiles.lua GUI\Pages\NicknamesPage.lua GUI\Pages\NicknamesPicker.lua diff --git a/GUI/Pages/Auras.lua b/GUI/Pages/Auras.lua new file mode 100644 index 00000000..9973f7ed --- /dev/null +++ b/GUI/Pages/Auras.lua @@ -0,0 +1,1754 @@ +-- Part 3 of the settings pages, split from Options.lua. +-- The parts run as a chain so the pages build in their original order. +local addonName, DF = ... +local format = string.format +function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) + pagePinnedFrames:SetScript("OnHide", function() + if DF.PinnedFrames then + DF.PinnedFrames:HidePreview() + end + end) + + -- General > Sorting + local pageSorting = CreateSubTab("general", "general_sorting", L["Sorting"]) + BuildPage(pageSorting, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + -- "useFrameSort" is the FrameSort integration toggle — a real per-mode key + -- that no prefix reached. (selfPosition / rolePriority / classPriority match + -- nothing: those settings are stored as sort*, which the first prefix covers. + -- Harmless, kept so the intent of the list stays readable.) + Add(CreateCopyButton(self.child, {"sort", "useFrameSort", "selfPosition", "rolePriority", "classPriority"}, L["Sorting"], "general_sorting"), 25, 2) + + -- Helper function to trigger sort for current mode + local function TriggerSortForCurrentMode() + if DF.testMode or DF.raidTestMode then + if DF.RefreshTestFramesWithLayout then DF:RefreshTestFramesWithLayout() end + return + end + if DF.headersInitialized then DF:ApplyHeaderSettings() end + -- Arena: ApplyHeaderSettings handles orientation but not sorting. + -- Call ApplyArenaHeaderSorting directly for settings changes from the GUI. + if DF.IsInArena and DF:IsInArena() then + if not InCombatLockdown() and DF.ApplyArenaHeaderSorting then + DF:ApplyArenaHeaderSorting() + end + elseif GUI.SelectedMode == "raid" then + if DF.SecureSort then + DF.SecureSort:PushRaidSortSettings() + DF.SecureSort:TriggerSecureRaidSort() + end + else + if DF.Sort then DF.Sort:TriggerResort() end + end + end + + -- When FrameSort takes over ordering, DF's own sort options are + -- irrelevant and HIDE (variant gate). Otherwise they GREY OUT + -- (disabled-in-place) while custom sorting is off rather than vanishing. + local function HideSortOptions(d) + return d.useFrameSort and FrameSortApi + end + local function DisableSortOptions(d) + return not d.sortEnabled + end + + -- Store reference to role widget so we can refresh it + local roleOrderWidget = nil + + -- ===== COMBAT STATUS BANNER (full width) ===== + local combatBanner = GUI:CreateInfoBanner(self.child, { fontTemplate = "DFFontNormal" }) + + local function UpdateCombatBanner() + if not db.sortEnabled then + combatBanner:Hide() + return + end + combatBanner:Show() + + local selfPos = db.sortSelfPosition or "SORTED" + local hasAdvancedOptions = db.sortSeparateMeleeRanged or db.sortByClass or db.sortAlphabetical + + if hasAdvancedOptions then + combatBanner:SetTone("danger") + combatBanner:SetText(L["Combat Limitation: All groups will not update with new players that join mid-combat."]) + elseif selfPos == "FIRST" or selfPos == "LAST" then + combatBanner:SetTone("caution") + -- Override default warning icon with info icon for this softer state. + combatBanner:SetIconTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\info") + combatBanner:SetText(L["Combat Limitation: Your group will not update with new players that join mid-combat."]) + else + combatBanner:SetTone("success") + combatBanner:SetText(L["Fully Combat Safe: Frames will update normally during combat."]) + end + end + + -- Hide when an external FrameSort addon owns sorting OR our sorting is off + -- (the combat banner is only meaningful while our sorting is enabled). + -- Without the sortEnabled check, the page's RefreshStates re-showed the + -- banner after UpdateCombatBanner had hidden it -- a white, tone-less box. + combatBanner.hideOn = function(d) return HideSortOptions(d) or not d.sortEnabled end + -- Reapply tone + text on every page refresh. RefreshStates calls + -- refreshContent (not the old custom UpdateBanner method), so the banner + -- never shows without a tone (the backdrop defaults to white until toned). + combatBanner.refreshContent = UpdateCombatBanner + Add(combatBanner, combatBanner.layoutHeight, "both") + + -- Initial update + UpdateCombatBanner() + + -- ===== SORTING OPTIONS GROUP (Column 1) ===== + local sortOptionsGroup = GUI:CreateSettingsGroup(self.child, 280) + sortOptionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Unit Frame Sorting"]), 40) + sortOptionsGroup:AddWidget(GUI:CreateLabel(self.child, L["Sort party members by role, class, and name.\n\nSort order: Self Position > Role > Class > Name"], 250), 60) + + local raidSortNote = sortOptionsGroup:AddWidget(GUI:CreateLabel(self.child, L["Raid: Group layout sorts within each group.\nFlat grid layout sorts all players together."], 250), 35) + raidSortNote.hideOn = function() return GUI.SelectedMode ~= "raid" end + + local sortEnable = sortOptionsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Custom Sorting"], db, "sortEnabled", function() + TriggerSortForCurrentMode() + UpdateCombatBanner() + self:RefreshStates() + end), 30) + sortEnable.keepEnabled = true + sortOptionsGroup.disableChildrenOn = DisableSortOptions + + local sortMeleeRanged = sortOptionsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Separate Melee & Ranged DPS"], db, "sortSeparateMeleeRanged", function() + TriggerSortForCurrentMode() + if roleOrderWidget and roleOrderWidget.Refresh then roleOrderWidget.Refresh() end + UpdateCombatBanner() + end), 30) + sortMeleeRanged.hideOn = HideSortOptions + + local sortByClass = sortOptionsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Sort by Class (within role)"], db, "sortByClass", function() + TriggerSortForCurrentMode() + self:RefreshStates() + UpdateCombatBanner() + end), 30) + sortByClass.hideOn = HideSortOptions + + local sortAlphaValues = { + [false] = L["Off"], + ["AZ"] = L["A to Z"], + ["ZA"] = L["Z to A"], + _order = {false, "AZ", "ZA"}, + } + local sortAlpha = sortOptionsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alphabetical (within class/role)"], sortAlphaValues, db, "sortAlphabetical", function() + TriggerSortForCurrentMode() + UpdateCombatBanner() + end), 55) + sortAlpha.hideOn = HideSortOptions + + Add(sortOptionsGroup, nil, 1) + + -- ===== FRAMESORT INTEGRATION GROUP (Column 1) ===== + if FrameSortApi then + local frameSortGroup = GUI:CreateSettingsGroup(self.child, 280) + frameSortGroup:AddWidget(GUI:CreateHeader(self.child, L["FrameSort Integration"]), 40) + frameSortGroup:AddWidget(GUI:CreateLabel(self.child, format(L["FrameSort addon detected. Enable to let FrameSort control frame ordering.\n\n%sExperimental:%s This feature is new and may not work perfectly in all scenarios. Please report any issues."], "|c" .. GUI:ToneHex("caution"), "|r"), 250), 70) + frameSortGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use FrameSort Addon"], db, "useFrameSort", function() + -- Set both modes simultaneously + local partyDB = DF:GetDB("party") + local raidDB = DF:GetDB("raid") + if partyDB then partyDB.useFrameSort = db.useFrameSort end + if raidDB then raidDB.useFrameSort = db.useFrameSort end + -- Notify the FrameSort module + if DF.FrameSort and DF.FrameSort.OnSettingChanged then + DF.FrameSort:OnSettingChanged() + end + -- Trigger a re-sort so the change takes effect immediately + TriggerSortForCurrentMode() + -- Refresh options visibility + self:RefreshStates() + end), 30) + Add(frameSortGroup, nil, 1) + end + + -- ===== SELF POSITION GROUP (Column 1) ===== + local selfPosGroup = GUI:CreateSettingsGroup(self.child, 280) + selfPosGroup:AddWidget(GUI:CreateHeader(self.child, L["Self Position"]), 40) + + local selfPosValues = { + ["FIRST"] = L["Always First"], + ["LAST"] = L["Always Last"], + ["SORTED"] = L["Sorted with Group"], + ["NORMAL"] = L["Sorted with Group"], + _order = {"FIRST", "LAST", "SORTED"}, + } + selfPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], selfPosValues, db, "sortSelfPosition", function() + TriggerSortForCurrentMode() + UpdateCombatBanner() + end), 55) + selfPosGroup.hideOn = HideSortOptions + selfPosGroup.disableChildrenOn = DisableSortOptions + Add(selfPosGroup, nil, 1) + + -- ===== ROLE PRIORITY GROUP (Column 2) ===== + local rolePriorityGroup = GUI:CreateSettingsGroup(self.child, 280) + rolePriorityGroup:AddWidget(GUI:CreateHeader(self.child, L["Role Priority"]), 40) + rolePriorityGroup:AddWidget(GUI:CreateLabel(self.child, L["Drag to reorder. Top = first."], 250), 25) + + roleOrderWidget = GUI:CreateRoleOrderList(self.child, db, "sortRoleOrder", function() + TriggerSortForCurrentMode() + end, "sortSeparateMeleeRanged") + rolePriorityGroup:AddWidget(roleOrderWidget, 135) + rolePriorityGroup.hideOn = HideSortOptions + rolePriorityGroup.disableChildrenOn = DisableSortOptions + Add(rolePriorityGroup, nil, 2) + + -- ===== CLASS PRIORITY GROUP (Column 2) ===== + local classPriorityGroup = GUI:CreateSettingsGroup(self.child, 280) + classPriorityGroup:AddWidget(GUI:CreateHeader(self.child, L["Class Priority"]), 40) + classPriorityGroup:AddWidget(GUI:CreateLabel(self.child, L["Drag to reorder. Top = first."], 250), 25) + + local classOrderWidget = GUI:CreateClassOrderList(self.child, db, "sortClassOrder", function() + TriggerSortForCurrentMode() + end) + classPriorityGroup:AddWidget(classOrderWidget, 320) + -- Hide under FrameSort takeover or when not sorting by class (variant + -- gates); grey out when custom sorting is disabled (enable gate). + classPriorityGroup.hideOn = function(d) return (d.useFrameSort and FrameSortApi) or not d.sortByClass end + classPriorityGroup.disableChildrenOn = DisableSortOptions + Add(classPriorityGroup, nil, 2) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "general_frame", label = L["Frame"]}, + {pageId = "general_labels", label = L["Group Labels"]}, + }), 30, "both") + end) + + -- General > Nicknames (account-wide list; custom builder, no party/raid switch) + local pageNicknames = CreateSubTab("general", "general_nicknames", L["Nicknames"]) + BuildPage(pageNicknames, function(self, db, Add, AddSpace, AddSyncPoint) + if DF.BuildNicknamesPage then + DF.BuildNicknamesPage(GUI, self, db, Add, AddSpace) + end + end) + + -- General > Integrations + local pageIntegrations = CreateSubTab("general", "general_integrations", L["Integrations"]) + BuildPage(pageIntegrations, function(self, db, Add, AddSpace, AddSyncPoint) + Add(CreateCopyButton(self.child, {"colorPicker"}, L["Integrations"], "general_integrations"), 25, 2) + -- ===== COLOR PICKER GROUP (Column 1) ===== + local colorPickerGroup = GUI:CreateSettingsGroup(self.child, 280) + colorPickerGroup:AddWidget(GUI:CreateHeader(self.child, L["Color Picker"]), 40) + + colorPickerGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use DF Color Picker"], db, "colorPickerOverride", function() + if db.colorPickerOverride or db.colorPickerGlobalOverride then + GUI:InstallColorPickerHook() + else + GUI:UninstallColorPickerHook() + end + if db.colorPickerOverride then + DF:Say("Color picker override enabled") + else + DF:Say("Color picker override disabled", nil, "WARN") + end + end), 30) + colorPickerGroup:AddWidget(GUI:CreateLabel(self.child, L["Replace Blizzard's color picker with the DandersFrames color picker for this addon."], 250), 40) + + colorPickerGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use DF Color Picker for All Addons"], db, "colorPickerGlobalOverride", function() + if db.colorPickerOverride or db.colorPickerGlobalOverride then + GUI:InstallColorPickerHook() + else + GUI:UninstallColorPickerHook() + end + if db.colorPickerGlobalOverride then + DF:Say("Custom color picker enabled for all addons") + else + DF:Say("Custom color picker disabled for all addons", nil, "WARN") + end + end), 30) + colorPickerGroup:AddWidget(GUI:CreateLabel(self.child, L["Show the DF color picker when any addon opens a color picker."], 250), 30) + + Add(colorPickerGroup, nil, 1) + + -- (Masque Integration group removed on 12.1: Masque cannot skin the + -- native container aura buttons — its script hooks and backdrops are + -- blocked on the protected buttons — so the toggle had no effect.) + + -- (Click-Through Icons group removed on 12.1: the container aura buttons + -- are always click-through by design — Blizzard's AlwaysPropagateInput + + -- the factory's unconditional SetMouseClickEnabled(false) — so the toggle + -- had no effect. Tooltips are governed by the Tooltips page instead.) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_buffs", label = L["Buffs"]}, + {pageId = "auras_debuffs", label = L["Debuffs"]}, + {pageId = "auras_defensiveicon", label = L["Defensive Icon"]}, + -- DEPRECATED-TARGETED-SPELLS: link dropped with the sidebar row. + }), 30, "both") + end) + + -- Display > Colors (was "Class Colors" pre-Stage 2; renamed when role + -- colours moved here so the page houses BOTH the class set and the role + -- set used by every border that opts into include.classColor / + -- include.roleColor in CreateBorderControls.) + local pageColors = CreateSubTab("display", "display_classcolors", L["Colors"]) + BuildPage(pageColors, function(self, db, Add, AddSpace, AddSyncPoint) + -- Class colors are shared across party/raid, stored at profile level + local classColorsDB = DF.db.classColors + if not classColorsDB then + DF.db.classColors = {} + classColorsDB = DF.db.classColors + end + + -- Role colors live at profile level too (DF.db.roleColors), seeded by + -- DF:MigrateRoleBorderColors on db load. + local roleColorsDB = DF.db.roleColors + if not roleColorsDB then + DF.db.roleColors = {} + roleColorsDB = DF.db.roleColors + end + + -- Ordered list of all classes with display names + local CLASS_LIST = { + { token = "WARRIOR", name = L["Warrior"] }, + { token = "PALADIN", name = L["Paladin"] }, + { token = "HUNTER", name = L["Hunter"] }, + { token = "ROGUE", name = L["Rogue"] }, + { token = "PRIEST", name = L["Priest"] }, + { token = "DEATHKNIGHT", name = L["Death Knight"] }, + { token = "SHAMAN", name = L["Shaman"] }, + { token = "MAGE", name = L["Mage"] }, + { token = "WARLOCK", name = L["Warlock"] }, + { token = "MONK", name = L["Monk"] }, + { token = "DRUID", name = L["Druid"] }, + { token = "DEMONHUNTER", name = L["Demon Hunter"] }, + { token = "EVOKER", name = L["Evoker"] }, + } + + local ROLE_LIST = { + { token = "TANK", name = L["Tank"] }, + { token = "HEALER", name = L["Healer"] }, + { token = "DAMAGER", name = L["Damager"] }, + } + local ROLE_DEFAULTS = { + TANK = {r = 0.20, g = 0.55, b = 0.95, a = 1}, + HEALER = {r = 0.20, g = 0.80, b = 0.30, a = 1}, + DAMAGER = {r = 0.85, g = 0.20, b = 0.20, a = 1}, + } + + -- ===== Column 1 ===== + local col1 = GUI:CreateSettingsGroup(self.child, 280) + col1:AddWidget(GUI:CreateHeader(self.child, L["Class Colors"]), 40) + col1:AddWidget(GUI:CreateLabel(self.child, L["Customize class colors used throughout DandersFrames. Changes apply to health bars, name text, borders, and all other class-colored elements."], 260), 50) + + -- Reset All button + local resetAllBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") + GUI:StyleButton(resetAllBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) + resetAllBtn:SetScript("OnClick", function() + -- Reset all to Blizzard defaults + for _, info in ipairs(CLASS_LIST) do + local default = RAID_CLASS_COLORS[info.token] + if default then + classColorsDB[info.token] = { r = default.r, g = default.g, b = default.b, a = 1 } + end + end + DF:RefreshAllVisibleFrames() + -- Refresh the options page to update swatches + if pageColors and pageColors.Refresh then + pageColors:Refresh() + end + end) + col1:AddWidget(resetAllBtn, 30) + + -- All classes in a single section + for i = 1, #CLASS_LIST do + local info = CLASS_LIST[i] + local token = info.token + -- Initialize from Blizzard defaults if not customized + if not classColorsDB[token] then + local default = RAID_CLASS_COLORS[token] + if default then + classColorsDB[token] = { r = default.r, g = default.g, b = default.b, a = 1 } + end + end + col1:AddWidget(GUI:CreateColorPicker(self.child, info.name, classColorsDB, token, false, function() + DF:RefreshAllVisibleFrames() + end, function() + DF:RefreshAllVisibleFrames() + end, true), 30) + end + + Add(col1, nil, 1) + + -- ===== Column 2: Role Colors ===== + local col2 = GUI:CreateSettingsGroup(self.child, 280) + col2:AddWidget(GUI:CreateHeader(self.child, L["Role Colors"]), 40) + col2:AddWidget(GUI:CreateLabel(self.child, L["Customize role colors used by any border whose Color Source is set to Role. Applies to Tank, Healer, and Damager assignments."], 260), 50) + + local roleResetBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") + GUI:StyleButton(roleResetBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) + roleResetBtn:SetScript("OnClick", function() + for _, info in ipairs(ROLE_LIST) do + local d = ROLE_DEFAULTS[info.token] + if d then roleColorsDB[info.token] = { r = d.r, g = d.g, b = d.b, a = d.a } end + end + DF:RefreshAllVisibleFrames() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end) + col2:AddWidget(roleResetBtn, 30) + + for i = 1, #ROLE_LIST do + local info = ROLE_LIST[i] + if not roleColorsDB[info.token] then + local d = ROLE_DEFAULTS[info.token] + if d then roleColorsDB[info.token] = { r = d.r, g = d.g, b = d.b, a = d.a } end + end + col2:AddWidget(GUI:CreateColorPicker(self.child, info.name, roleColorsDB, info.token, false, function() + DF:RefreshAllVisibleFrames() + end, function() + DF:RefreshAllVisibleFrames() + end, true), 30) + end + + Add(col2, nil, 2) + + -- ===== Column 1 (cont.): Dispel Colours ===== + -- Account-wide per-dispel-type palette (DF.db.dispelColors), the single source of + -- truth for both the debuff-icon border and the dispel overlay. Defaults ARE the + -- game palette (GetGameDispelPalette, queried from AuraUtil), so an untouched + -- palette matches the game exactly; the overlay always follows it, the icon when + -- "Color by Dispel Type" is on. No None/Physical picker — that border is hidden on + -- no-dispel-type auras and the overlay never fires on them. Editing re-drives frames. + local dispelColorsDB = DF.db.dispelColors + if type(dispelColorsDB) ~= "table" then + DF.db.dispelColors = {} + dispelColorsDB = DF.db.dispelColors + end + local dispelGamePalette = DF:GetGameDispelPalette() + local DISPEL_LIST = { + { key = "Magic", name = L["Magic"] }, + { key = "Curse", name = L["Curse"] }, + { key = "Disease", name = L["Disease"] }, + { key = "Poison", name = L["Poison"] }, + { key = "Bleed", name = L["Bleed / Enrage"] }, + } + -- Commit: bump the curve generation, rebuild the container rows (fresh + -- SetAuraBorder binds pick up the new curve) and restyle the overlay + -- (its binds re-run via the generation gate). + local function DispelColorChanged() + if DF.InvalidateDispelColorCurve then DF:InvalidateDispelColorCurve() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + if DF.UpdateAllFrames then DF:UpdateAllFrames() end + if DF.LightweightUpdateDispelOverlay then DF:LightweightUpdateDispelOverlay() end + end + -- Live (colour-wheel drag): cheap path — the overlay re-binds via the + -- generation gate and test-mode rings repaint on their ticker; live row + -- rings catch up on commit (a native bind can't retint per drag frame). + local function DispelColorLive() + if DF.InvalidateDispelColorCurve then DF:InvalidateDispelColorCurve() end + if DF.LightweightUpdateDispelOverlay then DF:LightweightUpdateDispelOverlay() end + end + local dispelCol = GUI:CreateSettingsGroup(self.child, 280) + dispelCol:AddWidget(GUI:CreateHeader(self.child, L["Dispel Type Colors"]), 40) + dispelCol:AddWidget(GUI:CreateLabel(self.child, L["Colours for each dispel type, used by the dispel overlay and the debuff-icon border (when Color by Dispel Type is on). Reset restores the game's colours."], 260), 55) + local dispelResetBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") + GUI:StyleButton(dispelResetBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) + dispelResetBtn:SetScript("OnClick", function() + for _, info in ipairs(DISPEL_LIST) do + local d = dispelGamePalette[info.key] + if d then dispelColorsDB[info.key] = { r = d.r, g = d.g, b = d.b } end + end + DispelColorChanged() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end) + dispelCol:AddWidget(dispelResetBtn, 30) + for i = 1, #DISPEL_LIST do + local info = DISPEL_LIST[i] + if type(dispelColorsDB[info.key]) ~= "table" then + local d = dispelGamePalette[info.key] + if d then dispelColorsDB[info.key] = { r = d.r, g = d.g, b = d.b } end + end + dispelCol:AddWidget(GUI:CreateColorPicker(self.child, info.name, dispelColorsDB, info.key, false, DispelColorChanged, DispelColorLive, true), 30) + end + Add(dispelCol, nil, 1) + + -- ===== Column 2 (cont.): Color by Time Remaining ===== + -- Account-wide duration-colour breakpoints, shared by the buff / debuff / defensive + -- rows AND the Aura Designer wherever "Color by Time Remaining" is enabled. Each stop + -- colours the duration text from its threshold upward; the highest threshold at or below + -- the time left wins. Editing here re-drives the aura formatters live (no /reload). + -- + -- Hybrid editor: a read-only preview strip (low time on the left, high on the right) + -- over one row per stop. Each row reuses CreateColorPicker with its LABEL set to the + -- human range it covers ("8s and above", "5-8s", "under 2s"), and a small +/- stepper + -- moves that stop's lower boundary. Colour edits re-tint the strip in place; boundary + -- add/remove/reset rebuild the page so every range label and the strip stay in sync. + -- TWO SECTIONS, one per consumer: duration TEXT and the expiry BORDER/TINT reveal. + -- An aura page only carries an on/off; everything about HOW the colour is read + -- lives here with the colours it reads, because that is a property of the ramp. + -- TEXT blends or steps (12.1's colour curve), on either scale. + -- BORDER steps only — its colours are baked into |T inline-texture escapes, + -- which ignore the fontstring vertex colour a curve writes. + -- Each section keeps ONE stop list PER SCALE: thresholds cannot be reinterpreted + -- between units (8 seconds is not 8 percent), so switching the scale swaps which + -- ramp is being edited and leaves the other untouched for switching back. + local cbtGlobalDB = DF:GetGlobalDB() + local iconPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + + local CBT_UNITS = { + SECONDS = { maxT = nil, capT = 600, -- seconds have no natural ceiling; derive from the stops + above = L["%ds and above"], under = L["under %ds"], range = L["%d-%ds"] }, + PERCENT = { maxT = 100, capT = 100, + above = L["%d%% and above"], under = L["under %d%%"], range = L["%d-%d%%"] }, + } + -- ONE RAMP PER UNIT, shared by every colour-by-time consumer — the duration text + -- AND the expiry border/tint reveal. "Time is running out" is one idea, so it gets + -- one set of colours. BOTH ramps are live at once: duration text reads whichever + -- its s/% toggle names (it has no threshold, so that is account-wide), while each + -- expiry reveal reads the one matching ITS OWN unit — per indicator, because a + -- reveal's bands and its Alert Below threshold are one formatter sampled against + -- one property. + local CBT_RAMPS = { + SECONDS = { bpKey = "durationColorByTimeBreakpoints" }, + PERCENT = { bpKey = "durationColorByPercentBreakpoints" }, + } + + local function ApplyColorByTime() + if DF.InvalidateDurationFormatters then DF:InvalidateDurationFormatters() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + if DF.UpdateAllFrames then DF:UpdateAllFrames() end + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + -- Expiry reveals read these ramps too, so an open Aura Designer card must + -- re-render its preview (no-ops if the designer was never opened). + if DF.AuraDesigner_RefreshPage then DF:AuraDesigner_RefreshPage() end + end + + -- The colour swatch fires its callback on EVERY change (many ticks per wheel drag), + -- so debounce the heavy re-drive: only the settled colour rebuilds the containers. + -- (add/remove/reset call ApplyColorByTime directly — discrete, no debounce needed.) + local cbtApplyTimer + local function ScheduleColorApply() + if cbtApplyTimer then cbtApplyTimer:Cancel() end + cbtApplyTimer = C_Timer.NewTimer(0.2, function() + cbtApplyTimer = nil + ApplyColorByTime() + end) + end + + -- Page-level collapsible section wrapping BOTH boxes, matching Health Bar and + -- friends. Its title is also the cross-link anchor every aura page flashes + -- (GUI:CreateColorsPageLink -> LinkToSetting{ section = L["Color by Time"] }) — + -- keep the two in step if it is ever renamed. Assigned just before the build + -- loop so it lands on the page ABOVE the boxes it owns. + local cbtSection + + -- ONE box (design "A"): Seconds/Percent TABS pick which unit's stops are being + -- EDITED (self._cbtEditUnit — page-local UI state, deliberately not saved), the + -- editor strip under them always shows HARD BANDS (the stops are data; how a + -- consumer renders them is the legend's job), and the "How this renders" legend + -- at the bottom is FIXED: a labelled preview per consumer, with that consumer's + -- own dials beside it. The previous layout drew the gradient on whichever ramp + -- the text happened to read, so flipping the text's unit visibly moved the + -- gradient between boxes — correct data, but it read as a bug. + local function BuildSection() + local editUnit = (self._cbtEditUnit == "PERCENT") and "PERCENT" or "SECONDS" + self._cbtEditUnit = editUnit + local scale = CBT_UNITS[editUnit] + local bpKey = CBT_RAMPS[editUnit].bpKey + + -- Seed BOTH ramps: the legend previews can show the non-edited unit (the text + -- preview always renders the ramp the text actually reads), so both lists must + -- exist whichever tab is up. + for _, ramp in pairs(CBT_RAMPS) do + if type(cbtGlobalDB[ramp.bpKey]) ~= "table" or #cbtGlobalDB[ramp.bpKey] == 0 then + cbtGlobalDB[ramp.bpKey] = DF:DeepCopy(DF.GlobalDefaults[ramp.bpKey]) + end + end + local bps = cbtGlobalDB[bpKey] + + local function cbtT(s) return math.max(0, tonumber(s and s.threshold) or 0) end + local function cbtSorted(descending) + local out = {} + for _, s in ipairs(bps) do out[#out + 1] = s end + table.sort(out, function(a, b) + if descending then return cbtT(a) > cbtT(b) else return cbtT(a) < cbtT(b) end + end) + return out + end + + local cbtGroup = GUI:CreateSettingsGroup(self.child, 280) + + -- Tabs: which unit's stops are on the editor below. UI state only — flipping a + -- tab changes nothing about what renders in the world. Underline-tab style + -- (StyleButton opts.tab — the PARTY/RAID/BINDS look), half-width each so the + -- pair spans the box. No explicit accent: the tab picks up the mode accent + -- (party purple / raid), same as the main tabs. CreateSegmentToggle stays the + -- compact value toggle beside a control (the s/% dials in the legend below). + local tabRow = CreateFrame("Frame", nil, self.child) + tabRow:SetSize(260, 24) + local prevTab + for _, def in ipairs({ + { key = "SECONDS", label = L["Seconds"] }, + { key = "PERCENT", label = L["Percent"] }, + }) do + local tabBtn = CreateFrame("Button", nil, tabRow, "BackdropTemplate") + GUI:StyleButton(tabBtn, { tab = true, width = 128, height = 24, text = def.label, font = "DFFontHighlight" }) + if prevTab then + tabBtn:SetPoint("LEFT", prevTab, "RIGHT", 4, 0) + else + tabBtn:SetPoint("LEFT", tabRow, "LEFT", 0, 0) + end + local key = def.key + tabBtn:SetScript("OnClick", function() + if self._cbtEditUnit ~= key then + self._cbtEditUnit = key + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end + end) + tabBtn:SetActive(editUnit == key) + prevTab = tabBtn + end + cbtGroup:AddWidget(tabRow, 30) + + -- Strip builder, shared by the editor strip and the legend previews. `unit` picks + -- which ramp the strip shows — the editor and border previews show the EDITED + -- unit, the text preview always shows the ramp the TEXT reads. smoothMode marks + -- the strips that render the way the duration text does (gradient while Blend + -- Colors Smoothly is on). Low values left, high right. + local previewW, stripH = 256, 18 + local strips = {} + local function BuildStrip(w, h, smoothMode, unit) + local f = CreateFrame("Frame", nil, self.child) + f:SetSize(w, h) + f.segs, f.smoothMode = {}, smoothMode + local asc = {} + for _, s2 in ipairs(cbtGlobalDB[CBT_RAMPS[unit].bpKey]) do asc[#asc + 1] = s2 end + table.sort(asc, function(a, b) return cbtT(a) < cbtT(b) end) + local maxT = CBT_UNITS[unit].maxT or math.max(12, cbtT(asc[#asc]) + 2) + for k = 1, #asc do + local lo = cbtT(asc[k]) + local hi = (k < #asc) and cbtT(asc[k + 1]) or maxT + if hi > lo then + local tex = f:CreateTexture(nil, "ARTWORK") + tex:SetPoint("TOPLEFT", f, "TOPLEFT", (lo / maxT) * w, 0) + tex:SetSize(((hi - lo) / maxT) * w, h) + tex:SetColorTexture(1, 1, 1, 1) -- white base; the gradient tints it + f.segs[#f.segs + 1] = { tex = tex, from = asc[k], to = asc[k + 1] } + end + end + strips[#strips + 1] = f + return f + end + local function RefreshPreview() + for _, f in ipairs(strips) do + local sm = f.smoothMode and (cbtGlobalDB.durationTextColorSmooth ~= false) + for _, seg in ipairs(f.segs) do + -- ONE path for both modes: reset the base to white, then gradient + -- a->b — stepped is just a->a, a solid band. The mode now flips at + -- RUNTIME (the Blend checkbox retints in place), and SetGradient + -- layered over a texture left as SetColorTexture(band colour) + -- MULTIPLIES the two into mud; the white reset makes every retint + -- idempotent. The band above the final stop stays flat because the + -- curve clamps there (probe-verified). + local a = seg.from.color or { r = 1, g = 1, b = 1 } + local b = (sm and seg.to and seg.to.color) or a + seg.tex:SetColorTexture(1, 1, 1, 1) + seg.tex:SetGradient("HORIZONTAL", + CreateColor(a.r or 1, a.g or 1, a.b or 1, 1), + CreateColor(b.r or 1, b.g or 1, b.b or 1, 1)) + end + end + end + + -- Editor strip: ALWAYS hard bands. The stops are the data being edited; whether a + -- consumer blends them is that consumer's property, previewed in the legend below. + cbtGroup:AddWidget(BuildStrip(previewW, stripH, false, editUnit), 24) + RefreshPreview() + + -- ONE row per stop, freshest (highest threshold) first: colour chip, computed + -- range, boundary stepper, remove. The chip IS a CreateColorPicker — shrunk to + -- its swatch by resizing the container (the button anchors TOPLEFT/TOPRIGHT, so + -- it follows) — which brings the whole dialog stack along (cancel restore, + -- Default button, ElvUI hook, spurious open-fire suppression) instead of + -- reimplementing any of it. The "Starts at" caption the merge dropped lives on + -- as a tooltip on the stepper controls. + local desc = cbtSorted(true) + for i = 1, #desc do + local bp = desc[i] + if type(bp.color) ~= "table" then bp.color = { r = 1, g = 1, b = 1 } end + local t = cbtT(bp) + local above = (i > 1) and cbtT(desc[i - 1]) or nil + local rangeLabel + if i == 1 then + rangeLabel = format(scale.above, t) + elseif t == 0 then + rangeLabel = format(scale.under, above or 0) + else + rangeLabel = format(scale.range, t, above or t) + end + + local row = CreateFrame("Frame", nil, self.child) + row:SetSize(previewW, 24) + + local chip = GUI:CreateColorPicker(self.child, "", bp, "color", false, function() + RefreshPreview() + ScheduleColorApply() + end, nil, false) + chip:SetParent(row) + chip:SetSize(52, 24) + chip:SetPoint("LEFT", row, "LEFT", 0, 0) + + local cap = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + cap:SetPoint("LEFT", chip, "RIGHT", 8, 0) + cap:SetText(rangeLabel) + cap:SetTextColor(0.85, 0.85, 0.85) + cap:SetWordWrap(false) + cap:SetJustifyH("LEFT") + + -- Boundary stepper (the t == 0 base band has no adjustable lower edge). + -- +/- nudge by one; the middle field is typeable for big jumps (8 -> 120). + if t ~= 0 then + local lowerBound = (i < #desc) and (cbtT(desc[i + 1]) + 1) or 1 + -- The top stop is capped by the scale (100% has a real ceiling; seconds + -- keep the long-buff headroom the field already allowed). + local upperBound = (i > 1) and (cbtT(desc[i - 1]) - 1) or scale.capT + + local eb + local function commitTo(nt) + nt = math.floor(tonumber(nt) or cbtT(bp)) + nt = math.max(lowerBound, math.min(upperBound, nt)) + if nt == cbtT(bp) then + if eb then eb:SetText(tostring(cbtT(bp))) end + return + end + bp.threshold = nt + ApplyColorByTime() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end + + -- Right-to-left: [−][value][+][×], the × only when removable. Stepper + -- rows all share the removable state, so their columns stay aligned. + local rightAnchor, rightPoint, rightOff = row, "RIGHT", -2 + if #bps > 2 then + -- Reuse the shared close-X in its default (dismiss) form — grey glyph at + -- rest, white glyph + red hover wash on mouseover, exactly like the GUI's + -- own close button. (No tone="danger" — that tints the glyph red at rest.) + local remBtn = GUI:CreateCloseButton(row, { + size = 20, + onClick = function() + for idx, s in ipairs(bps) do + if s == bp then table.remove(bps, idx) break end + end + ApplyColorByTime() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end, + }) + remBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0) + rightAnchor, rightPoint, rightOff = remBtn, "LEFT", -6 + end + + local plus = CreateFrame("Button", nil, row, "BackdropTemplate") + GUI:StyleButton(plus, { width = 22, height = 20, icon = { texture = iconPath .. "add", size = 12, color = { r = 0.85, g = 0.85, b = 0.85 } } }) + plus:SetPoint("RIGHT", rightAnchor, rightPoint, rightOff, 0) + plus:SetScript("OnClick", function() commitTo(cbtT(bp) + 1) end) + + eb = CreateFrame("EditBox", nil, row) + GUI:StyleEditBox(eb) + eb:SetSize(42, 20) + eb:SetPoint("RIGHT", plus, "LEFT", -4, 0) + eb:SetAutoFocus(false) + eb:SetNumeric(true) + eb:SetMaxLetters(4) + eb:SetJustifyH("CENTER") + eb:SetText(tostring(t)) + eb:SetCursorPosition(0) + eb:SetScript("OnEnterPressed", function(self) + local text = self:GetText() + self:ClearFocus() + commitTo(text) + end) + eb:SetScript("OnEscapePressed", function(self) + self:SetText(tostring(cbtT(bp))) + self:ClearFocus() + end) + eb:SetScript("OnEditFocusLost", function(self) + self:SetText(tostring(cbtT(bp))) + end) + + local minus = CreateFrame("Button", nil, row, "BackdropTemplate") + GUI:StyleButton(minus, { width = 22, height = 20, icon = { texture = iconPath .. "remove", size = 12, color = { r = 0.85, g = 0.85, b = 0.85 } } }) + minus:SetPoint("RIGHT", eb, "LEFT", -4, 0) + minus:SetScript("OnClick", function() commitTo(cbtT(bp) - 1) end) + + -- The merged row dropped the "Starts at" caption; the value box (only — + -- the +/- explain themselves) says it on hover instead. + eb:HookScript("OnEnter", function() GUI:ShowTooltip(eb, { title = L["Starts at"] }) end) + eb:HookScript("OnLeave", function() GUI:HideTooltip() end) + cap:SetPoint("RIGHT", minus, "LEFT", -6, 0) + else + cap:SetPoint("RIGHT", row, "RIGHT", -6, 0) + end + cbtGroup:AddWidget(row, 28) + end + + -- Add + Reset side by side: neither needs a full row to itself, and halving + -- them buys back a row of the height the stop merge just saved. + local stopBtnRow = CreateFrame("Frame", nil, self.child) + stopBtnRow:SetSize(260, 24) + local addStopBtn = CreateFrame("Button", nil, stopBtnRow, "BackdropTemplate") + GUI:StyleButton(addStopBtn, { width = 127, height = 24, text = L["Add Color Stop"] }) + addStopBtn:SetPoint("LEFT", stopBtnRow, "LEFT", 0, 0) + addStopBtn:SetScript("OnClick", function() + -- Drop a new stop into the widest existing gap. + local a2 = cbtSorted(false) + local mx = scale.maxT or math.max(12, cbtT(a2[#a2]) + 2) + local bestT, bestGap = nil, -1 + for k = 1, #a2 do + local lo = cbtT(a2[k]) + local hi = (k < #a2) and cbtT(a2[k + 1]) or mx + local mid = math.floor((lo + hi) / 2) + if (hi - lo) > bestGap and mid > lo and mid < hi then + bestGap = hi - lo + bestT = mid + end + end + if bestT then bps[#bps + 1] = { threshold = bestT, color = { r = 1, g = 1, b = 1 } } end + ApplyColorByTime() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end) + + local resetStopsBtn = CreateFrame("Button", nil, stopBtnRow, "BackdropTemplate") + GUI:StyleButton(resetStopsBtn, { width = 127, height = 24, text = L["Reset to Default"] }) + resetStopsBtn:SetPoint("RIGHT", stopBtnRow, "RIGHT", 0, 0) + resetStopsBtn:SetScript("OnClick", function() + wipe(bps) + for _, dv in ipairs(DF.GlobalDefaults[bpKey]) do + bps[#bps + 1] = { threshold = dv.threshold, color = { r = dv.color.r, g = dv.color.g, b = dv.color.b } } + end + ApplyColorByTime() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end) + cbtGroup:AddWidget(stopBtnRow, 30) + + -- ── HOW THIS RENDERS ───────────────────────────────────────────────────── + -- One labelled preview per consumer, each rendering the EDITED ramp through + -- that consumer's lens, with the consumer's own dials on its row. This block + -- never changes shape — only the pixels inside the previews. + cbtGroup:AddWidget(GUI:CreateExpiringSubheader(self.child, L["How this renders"]), 26) + + -- Duration text: ALWAYS the ramp the text actually reads (its own unit, whatever + -- tab is up), gradient while Blend is on. Never dimmed — this row answers "what + -- does my countdown text look like right now", and its s/% toggle switches which + -- ramp that is, which the preview shows by changing colours, not by greying. + local textRow = CreateFrame("Frame", nil, self.child) + textRow:SetSize(260, 22) + local textLbl = textRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + textLbl:SetPoint("LEFT", 0, 0) + textLbl:SetText(L["Duration Text"]) + textLbl:SetTextColor(0.85, 0.85, 0.85) + local textUnitNow = (cbtGlobalDB.durationTextColorScale == "SECONDS") and "SECONDS" or "PERCENT" + local textStrip = BuildStrip(108, 14, true, textUnitNow) + textStrip:SetParent(textRow) + textStrip:SetPoint("LEFT", textRow, "LEFT", 88, 0) + local textUnit = GUI:CreateSegmentToggle(self.child, { + { value = "SECONDS", label = L["s"], tooltip = L["Seconds"] }, + { value = "PERCENT", label = L["%"], tooltip = L["Percent"] }, + }, cbtGlobalDB, "durationTextColorScale", function() + ApplyColorByTime() + if pageColors and pageColors.Refresh then pageColors:Refresh() end + end, { segmentWidth = 26, height = 18 }) + textUnit:SetParent(textRow) + textUnit:SetPoint("RIGHT", textRow, "RIGHT", 0, 0) + cbtGroup:AddWidget(textRow, 26) + + -- Blend belongs to the TEXT, so it sits inside the text block — above the note, + -- not between the note and Border & Tint, where it read as a border dial (the + -- exact opposite of what it is: borders can never blend). Each block here is + -- "row, its dials, then its note", so the note is what closes a block. + cbtGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Blend Colors Smoothly"], cbtGlobalDB, + "durationTextColorSmooth", function() + ApplyColorByTime() + RefreshPreview() -- rendering-only: retint the text preview in place + end), 30) + + -- Covers BOTH dials above: durationTextColorScale and durationTextColorSmooth + -- are account-wide, unlike the border's per-indicator unit — the pair of notes + -- exists to make that contrast readable. + cbtGroup:AddWidget(GUI:CreateNote(self.child, L["Shared by all duration text."], + { width = 260 })) + + -- Border & tint: always hard bands (|T escapes ignore the vertex colour a curve + -- writes), reading whichever ramp matches each indicator's own unit. + local borderRow = CreateFrame("Frame", nil, self.child) + borderRow:SetSize(260, 22) + local borderLbl = borderRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + borderLbl:SetPoint("LEFT", 0, 0) + borderLbl:SetText(L["Border & Tint"]) + borderLbl:SetTextColor(0.85, 0.85, 0.85) + local borderStrip = BuildStrip(108, 14, false, editUnit) + borderStrip:SetParent(borderRow) + borderStrip:SetPoint("LEFT", borderRow, "LEFT", 88, 0) + local borderTag = borderRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + borderTag:SetPoint("RIGHT", borderRow, "RIGHT", -2, 0) + borderTag:SetText(L["steps"]) + borderTag:SetTextColor(0.55, 0.55, 0.55) + cbtGroup:AddWidget(borderRow, 26) + cbtGroup:AddWidget(GUI:CreateNote(self.child, + L["Set per indicator. Can't blend colors — always steps."], + { width = 260 })) + + RefreshPreview() -- tint the legend strips built after the first pass + Add(cbtGroup, nil, 2) + if cbtSection then cbtSection:RegisterChild(cbtGroup) end + end -- BuildSection + + -- Column 2, not "both": the header belongs over the box it owns rather than + -- spanning the page (column 1 holds the dispel palette, which it does not own). + -- Width matches the box so the rule under the title lines up with it. + cbtSection = Add(GUI:CreateCollapsibleSection(self.child, L["Color by Time"], true, 280), 36, 2) + BuildSection() + end) + + -- ======================================== + -- CATEGORY: Bars + -- ======================================== + CreateCategory("bars", L["Bars"]) + + -- Bars > Health Bar + local pageHealthBar = CreateSubTab("bars", "bars_health", L["Health Bar"]) + BuildPage(pageHealthBar, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"healthColor", "healthOrientation", "healthTexture", "classColor", "smoothBars", "background", "missingHealth", "reducedMaxHealth"}, L["Health Bar"], "bars_health"), 25, 2) + + local currentSection = nil + local function AddToSection(widget, height, col) + Add(widget, height, col) + if currentSection then currentSection:RegisterChild(widget) end + return widget + end + + -- ===== HEALTH BAR SECTION ===== + local healthBarSection = Add(GUI:CreateCollapsibleSection(self.child, L["Health Bar"], true), 36, "both") + currentSection = healthBarSection + + -- ===== COLOR GROUP (Column 1) ===== + local colorGroup = GUI:CreateSettingsGroup(self.child, 280) + colorGroup:AddWidget(GUI:CreateHeader(self.child, L["Color"]), 40) + + local colorModes = { CLASS= L["Class Color"], CUSTOM= L["Custom Color"], PERCENT= L["Health Gradient"] } + colorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], colorModes, db, "healthColorMode", function() + self:RefreshStates() + DF:UpdateColorCurve() + -- Refresh health colors on all frames (same as alpha slider) + DF:RefreshAllVisibleFrames() + end), 55) + + local classAlpha = colorGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Bar Alpha"], 0, 1, 0.05, db, "classColorAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + classAlpha.hideOn = function(d) return d.healthColorMode ~= "CLASS" and d.healthColorMode ~= "PERCENT" end + + local customColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Custom Health Color"], db, "healthColor", true, nil, function() DF:LightweightUpdateHealthColor() end, true), 35) + customColor.hideOn = function(d) return d.healthColorMode ~= "CUSTOM" end + + AddToSection(colorGroup, nil, 1) + + -- ===== TEXTURE GROUP (Column 2) ===== + local textureGroup = GUI:CreateSettingsGroup(self.child, 280) + textureGroup:AddWidget(GUI:CreateHeader(self.child, L["Texture"]), 40) + textureGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healthTexture"), 55) + + local orientOptions = { + HORIZONTAL= L["Left to Right"], HORIZONTAL_INV= L["Right to Left"], + VERTICAL= L["Bottom to Top"], VERTICAL_INV= L["Top to Bottom"], + } + textureGroup:AddWidget(GUI:CreateDropdown(self.child, L["Fill Direction"], orientOptions, db, "healthOrientation", function() DF:UpdateAllFrames() end), 55) + textureGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Smooth Bar Animation"], db, "smoothBars", function() DF:UpdateAllFrames() end), 30) + + AddToSection(textureGroup, nil, 2) + + -- ===== GRADIENT PREVIEW (full width, conditional) ===== + local gradHeader = AddToSection(GUI:CreateHeader(self.child, L["Gradient"]), 40, "both") + gradHeader.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end + + local gradBar = AddToSection(GUI:CreateGradientBar(self.child, 550, 24, db), 35, "both") + gradBar.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end + + -- ===== HIGH HEALTH GROUP (Column 1, conditional) ===== + local highGroup = GUI:CreateSettingsGroup(self.child, 280) + highGroup:AddWidget(GUI:CreateHeader(self.child, L["High Health (100%)"]), 40) + highGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "healthColorHigh", false, function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 35) + highGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "healthColorHighUseClass", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) + highGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "healthColorHighWeight", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 55) + highGroup.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end + AddToSection(highGroup, nil, 1) + + -- ===== MEDIUM HEALTH GROUP (Column 2, conditional) ===== + local medGroup = GUI:CreateSettingsGroup(self.child, 280) + medGroup:AddWidget(GUI:CreateHeader(self.child, L["Medium Health (50%)"]), 40) + medGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "healthColorMedium", false, function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 35) + medGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "healthColorMediumUseClass", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) + medGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "healthColorMediumWeight", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 55) + medGroup.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end + AddToSection(medGroup, nil, 2) + + -- ===== LOW HEALTH GROUP (Column 1, conditional) ===== + local lowGroup = GUI:CreateSettingsGroup(self.child, 280) + lowGroup:AddWidget(GUI:CreateHeader(self.child, L["Low Health (0%)"]), 40) + lowGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "healthColorLow", false, function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 35) + lowGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "healthColorLowUseClass", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) + lowGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "healthColorLowWeight", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 55) + lowGroup.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end + AddToSection(lowGroup, nil, 1) + + -- ===== BACKGROUND GROUP (Column 1) ===== + local bgGroup = GUI:CreateSettingsGroup(self.child, 280) + bgGroup:AddWidget(GUI:CreateHeader(self.child, L["Background"]), 40) + + local bgModes = { CUSTOM= L["Custom Color"], CLASS= L["Class Color"] } + bgGroup:AddWidget(GUI:CreateDropdown(self.child, L["Background Mode"], bgModes, db, "backgroundColorMode", function() + self:RefreshStates() + DF:LightweightUpdateBackgroundColor() + end), 55) + + local bgTextureOptions = DF:GetTextureList(true) + bgGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Background Texture"], db, "backgroundTexture", function() + DF:LightweightUpdateBackgroundColor() + end, bgTextureOptions), 55) + + local bgColor = bgGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "backgroundColor", true, nil, function() DF:LightweightUpdateBackgroundColor() end, true), 35) + bgColor.hideOn = function(d) return d.backgroundColorMode ~= "CUSTOM" end + + local bgClassAlpha = bgGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0, 1, 0.05, db, "backgroundClassAlpha", nil, function() DF:LightweightUpdateBackgroundColor() end, true), 55) + bgClassAlpha.hideOn = function(d) return d.backgroundColorMode ~= "CLASS" end + + AddToSection(bgGroup, nil, 1) + + currentSection = nil + AddSpace(GUI.Space.section, "both") + + -- ===== MISSING HEALTH SECTION ===== + local missingSection = Add(GUI:CreateCollapsibleSection(self.child, L["Missing Health"], true), 36, "both") + currentSection = missingSection + + -- ===== MISSING HEALTH GROUP (Column 1) ===== + local missingGroup = GUI:CreateSettingsGroup(self.child, 280) + missingGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + + local bgFillModes = { BACKGROUND= L["Background Only"], MISSING_HEALTH= L["Missing Health Only"], BOTH= L["Both"] } + local bgFillMode = missingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Background Fill"], bgFillModes, db, "backgroundMode", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 55) + bgFillMode.tooltip = L["Background Only: Normal solid background\nMissing Health Only: Shows colored bar where health is missing\nBoth: Shows both"] + + local missingHealthTextureOptions = DF:GetTextureList(false) + local missingHealthTexture = missingGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Missing Health Texture"], db, "missingHealthTexture", function() + DF:UpdateAllFrames() + end, missingHealthTextureOptions), 55) + missingHealthTexture.hideOn = function(d) return d.backgroundMode == "BACKGROUND" end + + local missingHealthColorModes = { CUSTOM= L["Custom Color"], CLASS= L["Class Color"], PERCENT= L["Health Gradient"] } + local missingHealthColorMode = missingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], missingHealthColorModes, db, "missingHealthColorMode", function() + self:RefreshStates() + DF:UpdateColorCurve() + DF:UpdateAllFrames() + end), 55) + missingHealthColorMode.hideOn = function(d) return d.backgroundMode == "BACKGROUND" end + + local missingHealthColor = missingGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Missing Health Color"], db, "missingHealthColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) + missingHealthColor.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "CUSTOM" end + + local missingHealthClassAlpha = missingGroup:AddWidget(GUI:CreateSlider(self.child, L["Class Color Alpha"], 0, 1, 0.05, db, "missingHealthClassAlpha", nil, function() DF:UpdateAllFrames() end, true), 55) + missingHealthClassAlpha.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "CLASS" end + + local missingHealthGradientAlpha = missingGroup:AddWidget(GUI:CreateSlider(self.child, L["Gradient Color Alpha"], 0, 1, 0.05, db, "missingHealthGradientAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + missingHealthGradientAlpha.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end + + AddToSection(missingGroup, nil, 1) + + -- ===== MISSING HEALTH GRADIENT PREVIEW (full width, conditional) ===== + local mhGradHeader = AddToSection(GUI:CreateHeader(self.child, L["Gradient"]), 40, "both") + mhGradHeader.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end + + local mhGradBar = AddToSection(GUI:CreateGradientBar(self.child, 550, 24, db, "missingHealthColor"), 35, "both") + mhGradBar.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end + + -- ===== MISSING HEALTH HIGH GROUP (Column 1, conditional) ===== + local mhHighGroup = GUI:CreateSettingsGroup(self.child, 280) + mhHighGroup:AddWidget(GUI:CreateHeader(self.child, L["High Health (100%)"]), 40) + mhHighGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "missingHealthColorHigh", false, function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 35) + mhHighGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "missingHealthColorHighUseClass", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) + mhHighGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "missingHealthColorHighWeight", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 55) + mhHighGroup.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end + AddToSection(mhHighGroup, nil, 1) + + -- ===== MISSING HEALTH MEDIUM GROUP (Column 2, conditional) ===== + local mhMedGroup = GUI:CreateSettingsGroup(self.child, 280) + mhMedGroup:AddWidget(GUI:CreateHeader(self.child, L["Medium Health (50%)"]), 40) + mhMedGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "missingHealthColorMedium", false, function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 35) + mhMedGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "missingHealthColorMediumUseClass", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) + mhMedGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "missingHealthColorMediumWeight", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 55) + mhMedGroup.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end + AddToSection(mhMedGroup, nil, 2) + + -- ===== MISSING HEALTH LOW GROUP (Column 1, conditional) ===== + local mhLowGroup = GUI:CreateSettingsGroup(self.child, 280) + mhLowGroup:AddWidget(GUI:CreateHeader(self.child, L["Low Health (0%)"]), 40) + mhLowGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "missingHealthColorLow", false, function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 35) + mhLowGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "missingHealthColorLowUseClass", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) + mhLowGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "missingHealthColorLowWeight", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 55) + mhLowGroup.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end + AddToSection(mhLowGroup, nil, 1) + + currentSection = nil + + AddSpace(GUI.Space.section, "both") + + -- ===== REDUCED MAX HEALTH SECTION ===== + local reducedSection = Add(GUI:CreateCollapsibleSection(self.child, L["Reduced Max Health"], true), 36, "both") + currentSection = reducedSection + + -- ===== REDUCED MAX HEALTH SETTINGS GROUP (Column 1) ===== + local reducedGroup = GUI:CreateSettingsGroup(self.child, 280) + reducedGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + local reducedEnable = reducedGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable"], db, "reducedMaxHealthEnabled", function() self:RefreshStates() DF:UpdateAllFrames() end), 30) + reducedEnable.keepEnabled = true + reducedGroup.disableChildrenOn = function(d) return not d.reducedMaxHealthEnabled end + reducedGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Clip Health Bar"], db, "reducedMaxHealthClipHealthBar", function() DF:UpdateAllFrames() end), 30) + reducedGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "reducedMaxHealthTexture", function() DF:UpdateAllFrames() end), 55) + reducedGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "reducedMaxHealthColor", true, nil, function() DF:LightweightUpdateReducedMaxHealthColor() end, true), 35) + local reducedBlendOpts = { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"] } + reducedGroup:AddWidget(GUI:CreateDropdown(self.child, L["Blend Mode"], reducedBlendOpts, db, "reducedMaxHealthBlendMode", function() DF:UpdateAllFrames() end), 55) + AddToSection(reducedGroup, nil, 1) + + currentSection = nil + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "general_frame", label = L["Frame"]}, + -- LEGACY-TEXT-CLEANUP: legacy text page hidden; link removed + -- {pageId = "text_health", label = L["Health Text"]}, + {pageId = "bars_absorbs", label = L["Absorbs"]}, + }), 30, "both") + end) + + -- Bars > Resource Bar + local pageResource = CreateSubTab("bars", "bars_resource", L["Resource Bar"]) + BuildPage(pageResource, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"resourceBar"}, L["Resource Bar"], "bars_resource"), 25, 2) + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Resource Bar Settings"]), 40) + local resourceBarEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Resource Bar"], db, "resourceBarEnabled", function() + DF:UpdateAllPowerEventRegistration() + DF:UpdateAllFrames() + self:RefreshStates() + end), 30) + resourceBarEnable.keepEnabled = true + settingsGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Healers"], db, "resourceBarShowHealer", function() DF:UpdateAllFrames() end), 30) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Tanks"], db, "resourceBarShowTank", function() DF:UpdateAllFrames() end), 30) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["DPS"], db, "resourceBarShowDPS", function() DF:UpdateAllFrames() end), 30) + local showInSolo = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show in Solo Mode"], db, "resourceBarShowInSoloMode", function() DF:UpdateAllFrames() end), 30) + showInSolo.hideOn = function() return GUI.SelectedMode == "raid" end + Add(settingsGroup, nil, 1) + + -- ===== CLASS FILTER GROUP (Column 1) ===== + local classFilterGroup = GUI:CreateSettingsGroup(self.child, 280) + classFilterGroup:AddWidget(GUI:CreateHeader(self.child, L["Class Filter"]), 40) + classFilterGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + + local RB_CLASS_LIST = { + { token = "WARRIOR", name = L["Warrior"] }, + { token = "PALADIN", name = L["Paladin"] }, + { token = "HUNTER", name = L["Hunter"] }, + { token = "ROGUE", name = L["Rogue"] }, + { token = "PRIEST", name = L["Priest"] }, + { token = "DEATHKNIGHT", name = L["Death Knight"] }, + { token = "SHAMAN", name = L["Shaman"] }, + { token = "MAGE", name = L["Mage"] }, + { token = "WARLOCK", name = L["Warlock"] }, + { token = "MONK", name = L["Monk"] }, + { token = "DRUID", name = L["Druid"] }, + { token = "DEMONHUNTER", name = L["Demon Hunter"] }, + { token = "EVOKER", name = L["Evoker"] }, + } + + if not db.resourceBarClassFilter then + db.resourceBarClassFilter = {} + for _, info in ipairs(RB_CLASS_LIST) do + db.resourceBarClassFilter[info.token] = true + end + end + + for _, info in ipairs(RB_CLASS_LIST) do + classFilterGroup:AddWidget( + GUI:CreateCheckbox(self.child, info.name, db.resourceBarClassFilter, info.token, function() + DF:UpdateAllFrames() + end), 25 + ) + end + Add(classFilterGroup, nil, 1) + + -- ===== SIZE GROUP (Column 1) ===== + local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) + sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Size"]), 40) + sizeGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + local rbMatch = sizeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Match Health Bar Width/Height"], db, "resourceBarMatchWidth", function() DF:UpdateAllFrames() end), 30) + rbMatch.tooltip = L["Keeps the resource bar the same width as the health bar it sits under, so it stays lined up when you resize the frame. The Width slider below greys out while this is on."] + local widthSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Width / Length"], 10, 200, 1, db, "resourceBarWidth", nil, function() DF:LightweightUpdatePowerBarSize() end, true), 55) + widthSlider.disableOn = function(d) return d.resourceBarMatchWidth end + sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Height / Thickness"], 1, 20, 1, db, "resourceBarHeight", nil, function() DF:LightweightUpdatePowerBarSize() end, true), 55) + Add(sizeGroup, nil, 1) + + -- ===== POSITION GROUP (Column 1) ===== + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + positionGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + + local anchorOptions = { + TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], CENTER= L["Center"], + TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], + } + positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "resourceBarAnchor", function() DF:UpdateAllFrames() end), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "resourceBarX", nil, function() DF:LightweightUpdatePowerBarPosition() end, true), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "resourceBarY", nil, function() DF:LightweightUpdatePowerBarPosition() end, true), 55) + Add(positionGroup, nil, 1) + + -- ===== APPEARANCE GROUP (Column 2) — mirrors the Health Bar's Texture group: + -- Texture, Orientation / Reverse Fill, and Smooth Bar Animation in one place. ===== + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + appearanceGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + appearanceGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "resourceBarTexture", function() DF:UpdateAllFrames() end), 55) + + -- Keep Orientation (Horizontal/Vertical) and Reverse Fill as two explicit + -- controls — clearer than a combined "Fill Direction" dropdown, where an + -- option like "Bottom to Top" silently changes the orientation too. + local orientOptions = { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] } + appearanceGroup:AddWidget(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "resourceBarOrientation", function() DF:UpdateAllFrames() end), 55) + appearanceGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill Direction"], db, "resourceBarReverseFill", function() DF:UpdateAllFrames() end), 30) + + appearanceGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Smooth Bar Animation"], db, "resourceBarSmooth", function() DF:UpdateAllFrames() end), 30) + Add(appearanceGroup, nil, 2) + + -- ===== BACKGROUND GROUP (Column 2) ===== + local bgGroup = GUI:CreateSettingsGroup(self.child, 280) + bgGroup:AddWidget(GUI:CreateHeader(self.child, L["Background"]), 40) + bgGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + bgGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Background"], db, "resourceBarBackgroundEnabled", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 30) + local bgColor = bgGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "resourceBarBackgroundColor", true, nil, function() DF:LightweightUpdateResourceBarBackgroundColor() end, true), 35) + bgColor.disableOn = function(d) return not d.resourceBarBackgroundEnabled end + Add(bgGroup, nil, 2) + + -- ===== BORDER GROUP (Column 2) ===== + -- Stage 4.2: hand-rolled Show + Colour block expanded to the full + -- unified helper. include set tailored for a resource indicator: + -- alpha / inset / blendMode / gradient / shadow keep the visual + -- toolkit; classColor / roleColor match the bar's optional class + -- tinting (resourceBarClassColor) for cohesion. Skipped: animate + -- (resource bar is decoration, not an alert surface), offset (bar + -- has its own X/Y positioning controls above), colorByTime / + -- colorByType (no aura-state context). + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + borderGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + GUI:CreateBorderControls(borderGroup, db, "resourceBar", { + parent = self.child, + include = { alpha = true, inset = true, blendMode = true, + gradient = true, shadow = true, + classColor = true, roleColor = true }, + fullUpdate = function() DF:LightweightUpdateResourceBarBorder() end, + lightUpdate = function() DF:LightweightUpdateResourceBarBorder() end, + lightColors = function() DF:LightweightUpdateResourceBarBorderColor() end, + refreshStates = function() self:RefreshStates() end, + sizeMin = 1, sizeMax = 6, sizeStep = 1, + }) + Add(borderGroup, nil, 2) + + -- ===== FRAME LEVEL GROUP (Column 1) ===== + local frameLevelGroup = GUI:CreateSettingsGroup(self.child, 280) + frameLevelGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Level"]), 40) + frameLevelGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + frameLevelGroup:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "resourceBarFrameLevel", nil, function() DF:LightweightUpdateResourceBarFrameLevel() end, true)), 55) + Add(frameLevelGroup, nil, 1) + + -- ===== RESOURCE COLORS GROUP (Column 2) ===== + local colorGroup = GUI:CreateSettingsGroup(self.child, 280) + colorGroup:AddWidget(GUI:CreateHeader(self.child, L["Resource Colors"]), 40) + colorGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end + colorGroup:AddWidget(GUI:CreateLabel(self.child, L["Customize resource bar colors per power type. Shared across party and raid frames."], 260), 40) + -- Colour mode: Power Type (per-power colours below) / Class / Custom. + local RESOURCE_COLOR_MODES = { + POWER_TYPE = L["Power Type"], CLASS = L["Class"], CUSTOM = L["Custom"], + _order = { "POWER_TYPE", "CLASS", "CUSTOM" }, + } + local rbColorMode = colorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], RESOURCE_COLOR_MODES, db, "resourceBarColorMode", function() + DF:RefreshAllVisibleFrames() + self:RefreshStates() -- re-evaluate the custom colour picker's hideOn + end), 54) + rbColorMode.tooltip = L["Power Type gives each resource its own game colour — blue mana, yellow energy, red rage. Class colours every bar by the unit's class instead, and Custom uses one fixed colour for everyone."] + + -- Custom colour — only shown in Custom mode. + local resourceCustomColor = GUI:CreateColorPicker(self.child, L["Custom Color"], db, "resourceBarCustomColor", false, function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true) + resourceCustomColor.hideOn = function() return (db.resourceBarColorMode or "POWER_TYPE") ~= "CUSTOM" end + colorGroup:AddWidget(resourceCustomColor, 30) + + local powerColorsDB = DF.db.powerColors + if not powerColorsDB then + DF.db.powerColors = {} + powerColorsDB = DF.db.powerColors + end + + local POWER_LIST = { + { token = "MANA", name = L["Mana"] }, + { token = "RAGE", name = L["Rage"] }, + { token = "FOCUS", name = L["Focus"] }, + { token = "ENERGY", name = L["Energy"] }, + { token = "RUNIC_POWER", name = L["Runic Power"] }, + { token = "INSANITY", name = L["Insanity"] }, + { token = "FURY", name = L["Fury"] }, + { token = "PAIN", name = L["Pain"] }, + { token = "LUNAR_POWER", name = L["Lunar Power"] }, + { token = "MAELSTROM", name = L["Maelstrom"] }, + } + + for _, info in ipairs(POWER_LIST) do + local token = info.token + if not powerColorsDB[token] then + local default = PowerBarColor[token] + if default then + powerColorsDB[token] = { r = default.r, g = default.g, b = default.b, a = 1 } + end + end + colorGroup:AddWidget(GUI:CreateColorPicker(self.child, info.name, powerColorsDB, token, false, function() + DF:RefreshAllVisibleFrames() + end, function() + DF:RefreshAllVisibleFrames() + end, true), 30) + end + + -- Reset button + local resetPowerBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") + GUI:StyleButton(resetPowerBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) + resetPowerBtn:SetScript("OnClick", function() + for _, info in ipairs(POWER_LIST) do + local default = PowerBarColor[info.token] + if default then + powerColorsDB[info.token] = { r = default.r, g = default.g, b = default.b, a = 1 } + end + end + DF:RefreshAllVisibleFrames() + if pageResource and pageResource.Refresh then + pageResource:Refresh() + end + end) + colorGroup:AddWidget(resetPowerBtn, 30) + + Add(colorGroup, nil, 2) + end) + + -- Bars > Absorbs (combined Absorb Shield + Heal Absorb with collapsible sections) + local pageAbsorb = CreateSubTab("bars", "bars_absorb", L["Absorbs"]) + BuildPage(pageAbsorb, function(self, db, Add, AddSpace) + -- Copy button at top + Add(CreateCopyButton(self.child, {"absorbBar", "healAbsorb"}, L["Absorbs"], "bars_absorb"), 25, 2) + + local currentSection = nil + + -- Helper to add widgets to current section + local function AddToSection(widget, height, col) + Add(widget, height, col) + if currentSection then + currentSection:RegisterChild(widget) + end + return widget + end + + -- ===== ABSORB SHIELD SECTION ===== + local absorbSection = Add(GUI:CreateCollapsibleSection(self.child, L["Absorb Shield"], true), 36, "both") + currentSection = absorbSection + + local modeOptions = { + OVERLAY = L["Overlay (on health bar)"], + ATTACHED = L["Attached to Health"], + ATTACHED_OVERFLOW = L["Attached + Overflow"], + FLOATING = L["Floating Bar"], + } + AddToSection(GUI:CreateDropdown(self.child, L["Display Mode"], modeOptions, db, "absorbBarMode", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 55, 1) + + local textureOptions = DF:GetTextureList() + -- Add stripe textures if not already present + local stripeTextures = { + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft"]= "DF Stripes Soft", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft_Wide"]= "DF Stripes Soft Wide", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes"]= "DF Stripes", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Sparse"]= "DF Stripes Sparse", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Medium"]= "DF Stripes Medium", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense"]= "DF Stripes Dense", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Very_Dense"]= "DF Stripes Very Dense", + } + for path, name in pairs(stripeTextures) do + if not textureOptions[path] then + textureOptions[path] = name + end + end + AddToSection(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "absorbBarTexture", function() DF:UpdateAllFrames() end, textureOptions), 55, 1) + + AddToSection(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "absorbBarColor", true, nil, function() DF:LightweightUpdateAbsorbBarColor() end, true), 35, 1) + + local blendOptions = { BLEND= L["Normal (BLEND)"], ADD= L["Additive (ADD)"] } + AddToSection(GUI:CreateDropdown(self.child, L["Blend Mode"], blendOptions, db, "absorbBarBlendMode", function() DF:UpdateAllFrames() end), 55, 1) + + local overlayRev = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Overlay Fill"], db, "absorbBarOverlayReverse", function() DF:UpdateAllFrames() end), 25, 1) + overlayRev.hideOn = function(d) return d.absorbBarMode ~= "OVERLAY" and d.absorbBarMode ~= "ATTACHED_OVERFLOW" end + + local absorbClampOptions = { + [0] = L["None (no clamping)"], + [1] = L["Missing Health"], + [2] = L["Max Health"], + } + local absorbClampDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Clamp Mode"], absorbClampOptions, db, "absorbBarAttachedClampMode", function() DF:UpdateAllFrames() end), 55, 1) + absorbClampDropdown.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" and d.absorbBarMode ~= "ATTACHED_OVERFLOW" end + + local absorbShowOvershield = AddToSection(GUI:CreateCheckbox(self.child, L["Show Overshield Glow"], db, "absorbBarShowOvershield", function() DF:UpdateAllFrames() end), 25, 1) + absorbShowOvershield.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end + absorbShowOvershield.tooltip = L["Shows a glow at max health when absorb exceeds the clamp limit."] + + local absorbOvershieldStyleOptions = { + SPARK = L["Spark"], + LINE = L["Line"], + GRADIENT = L["Gradient"], + GLOW = L["Glow"], + } + -- Overshield glow detail controls: HIDE for the wrong bar mode (variant), but + -- GREY (disabled-in-place) when the boolean "Show Overshield Glow" toggle is off. + local absorbOvershieldStyle = AddToSection(GUI:CreateDropdown(self.child, L["Glow Style"], absorbOvershieldStyleOptions, db, "absorbBarOvershieldStyle", function() DF:UpdateAllFrames() end), 55, 1) + absorbOvershieldStyle.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end + absorbOvershieldStyle.disableOn = function(d) return not d.absorbBarShowOvershield end + + local absorbOvershieldColor = AddToSection(GUI:CreateColorPicker(self.child, L["Glow Color"], db, "absorbBarOvershieldColor", false, nil, function() DF:UpdateAllFrames() end), 35, 1) + absorbOvershieldColor.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end + absorbOvershieldColor.disableOn = function(d) return not d.absorbBarShowOvershield end + + local absorbOvershieldAlpha = AddToSection(GUI:CreateSlider(self.child, L["Glow Alpha"], 0.1, 1, 0.05, db, "absorbBarOvershieldAlpha", nil, function() DF:UpdateAllFrames() end, true), 55, 1) + absorbOvershieldAlpha.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end + absorbOvershieldAlpha.disableOn = function(d) return not d.absorbBarShowOvershield end + + local absorbOvershieldReverse = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Position"], db, "absorbBarOvershieldReverse", function() DF:UpdateAllFrames() end), 25, 1) + absorbOvershieldReverse.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end + absorbOvershieldReverse.disableOn = function(d) return not d.absorbBarShowOvershield end + absorbOvershieldReverse.tooltip = L["Moves the glow to the opposite side (no HP side instead of max HP side)."] + + -- Floating mode settings (column 2) + local floatingHeader = AddToSection(GUI:CreateHeader(self.child, L["Floating Bar Position"]), 45, 2) + floatingHeader.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local orientOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } + local orientDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "absorbBarOrientation", function() DF:UpdateAllFrames() end), 55, 1) + orientDropdown.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local revFill = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "absorbBarReverse", function() DF:UpdateAllFrames() end), 25, 2) + revFill.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local widthSlider = AddToSection(GUI:CreateSlider(self.child, L["Width"], 10, 200, 1, db, "absorbBarWidth", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) + widthSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local heightSlider = AddToSection(GUI:CreateSlider(self.child, L["Height"], 1, 30, 1, db, "absorbBarHeight", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) + heightSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local anchorOptions = { + TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], CENTER= L["Center"], + TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], + } + local anchorDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "absorbBarAnchor", function() DF:UpdateAllFrames() end), 55, 1) + anchorDropdown.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local xSlider = AddToSection(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "absorbBarX", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) + xSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local ySlider = AddToSection(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "absorbBarY", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) + ySlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local bgColorPicker = AddToSection(GUI:CreateColorPicker(self.child, L["Background Color"], db, "absorbBarBackgroundColor", true, nil, function() DF:LightweightUpdateAbsorbBarColor() end, true), 35, 2) + bgColorPicker.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + local levelSlider = AddToSection(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "absorbBarFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("absorb") end, true)), 55, 1) + levelSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end + + currentSection = nil + AddSpace(GUI.Space.section, "both") + + -- ===== HEAL ABSORB SECTION ===== + local healAbsorbSection = Add(GUI:CreateCollapsibleSection(self.child, L["Heal Absorb"], true), 36, "both") + currentSection = healAbsorbSection + + AddToSection(GUI:CreateLabel(self.child, L["Shows effects that reduce incoming healing (like Necrotic stacks)."], 260), 25, 1) + + local healModeOptions = { + OVERLAY = L["Overlay (on health bar)"], + ATTACHED = L["Attached to Health"], + FLOATING = L["Floating Bar"], + } + AddToSection(GUI:CreateDropdown(self.child, L["Display Mode"], healModeOptions, db, "healAbsorbBarMode", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 55, 1) + + local healTextureOptions = DF:GetTextureList() + -- Add stripe textures if not already present + local healStripeTextures = { + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft"]= "DF Stripes Soft", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft_Wide"]= "DF Stripes Soft Wide", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes"]= "DF Stripes", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Sparse"]= "DF Stripes Sparse", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Medium"]= "DF Stripes Medium", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense"]= "DF Stripes Dense", + ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Very_Dense"]= "DF Stripes Very Dense", + } + for path, name in pairs(healStripeTextures) do + if not healTextureOptions[path] then + healTextureOptions[path] = name + end + end + AddToSection(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healAbsorbBarTexture", function() DF:UpdateAllFrames() end, healTextureOptions), 55, 1) + + AddToSection(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "healAbsorbBarColor", true, nil, function() DF:LightweightUpdateHealAbsorbBarColor() end, true), 35, 1) + + local healBlendOptions = { BLEND= L["Normal (BLEND)"], ADD= L["Additive (ADD)"] } + AddToSection(GUI:CreateDropdown(self.child, L["Blend Mode"], healBlendOptions, db, "healAbsorbBarBlendMode", function() DF:UpdateAllFrames() end), 55, 1) + + local healOverlayRev = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Overlay Fill"], db, "healAbsorbBarOverlayReverse", function() DF:UpdateAllFrames() end), 25, 1) + healOverlayRev.hideOn = function(d) return d.healAbsorbBarMode ~= "OVERLAY" end + + -- Heal Absorb Floating mode settings (column 2) + local healFloatingHeader = AddToSection(GUI:CreateHeader(self.child, L["Floating Bar Position"]), 45, 2) + healFloatingHeader.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healOrientDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "healAbsorbBarOrientation", function() DF:UpdateAllFrames() end), 55, 1) + healOrientDropdown.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healRevFill = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "healAbsorbBarReverse", function() DF:UpdateAllFrames() end), 25, 2) + healRevFill.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healWidthSlider = AddToSection(GUI:CreateSlider(self.child, L["Width"], 10, 200, 1, db, "healAbsorbBarWidth", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) + healWidthSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healHeightSlider = AddToSection(GUI:CreateSlider(self.child, L["Height"], 1, 30, 1, db, "healAbsorbBarHeight", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) + healHeightSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healAnchorDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "healAbsorbBarAnchor", function() DF:UpdateAllFrames() end), 55, 1) + healAnchorDropdown.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healXSlider = AddToSection(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "healAbsorbBarX", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) + healXSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healYSlider = AddToSection(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "healAbsorbBarY", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) + healYSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + local healBgColorPicker = AddToSection(GUI:CreateColorPicker(self.child, L["Background Color"], db, "healAbsorbBarBackgroundColor", true, nil, function() DF:LightweightUpdateHealAbsorbBarColor() end, true), 35, 2) + healBgColorPicker.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end + + currentSection = nil + end) + + -- Bars > Heal Prediction + local pageHealPrediction = CreateSubTab("bars", "bars_healpred", L["Heal Prediction"]) + BuildPage(pageHealPrediction, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"healPrediction"}, L["Heal Prediction"], "bars_healpred"), 25, 2) + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Heal Prediction"]), 40) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Heal Prediction"], db, "healPredictionEnabled", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 30) + + local overhealCheckbox = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Overheal"], db, "healPredictionShowOverheal", function() DF:UpdateAllFrames() end), 30) + overhealCheckbox.disableOn = function(d) return not d.healPredictionEnabled end + overhealCheckbox.tooltip = L["When enabled, shows incoming heals even if they would overheal."] + + local modeOptions = { OVERLAY= L["Attached to Health"], FLOATING= L["Floating Bar"] } + local modeDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Display Mode"], modeOptions, db, "healPredictionMode", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 55) + modeDropdown.disableOn = function(d) return not d.healPredictionEnabled end + + local showModeOptions = { + ALL = L["All Incoming"], MINE = L["My Heals"], OTHERS = L["Others' Heals"], + SPLIT = L["Split (Mine + Others)"], + _order = { "ALL", "MINE", "OTHERS", "SPLIT" }, + } + local showModeDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Show Heals From"], showModeOptions, db, "healPredictionShowMode", function() + -- Rebuild so the colour picker(s) rebind to the selected mode. + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + DF:UpdateAllFrames() + end), 55) + showModeDropdown.disableOn = function(d) return not d.healPredictionEnabled end + showModeDropdown.tooltip = L["Which incoming heals the bar shows: all sources, only yours, or only from others."] + + local textureOptions = DF:GetTextureList() + local texDropdown = settingsGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healPredictionTexture", function() DF:UpdateAllFrames() end, textureOptions), 55) + texDropdown.disableOn = function(d) return not d.healPredictionEnabled end + + -- Colour picker(s): Split shows both segment colours; other modes show + -- the single colour for the selected mode. The mode dropdown rebuilds + -- the page so these rebind when the mode changes. + if db.healPredictionShowMode == "SPLIT" then + local myColor = settingsGroup:AddWidget(GUI:CreateColorPicker(self.child, L["My Heals Color"], db, "healPredictionMyColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) + myColor.disableOn = function(d) return not d.healPredictionEnabled end + local othersColor = settingsGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Others' Heals Color"], db, "healPredictionOthersColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) + othersColor.disableOn = function(d) return not d.healPredictionEnabled end + else + local showModeColorKey = (db.healPredictionShowMode == "ALL" and "healPredictionAllColor") + or (db.healPredictionShowMode == "OTHERS" and "healPredictionOthersColor") + or "healPredictionMyColor" + local myColor = settingsGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Heal Prediction Color"], db, showModeColorKey, true, nil, function() DF:UpdateAllFrames() end, true), 35) + myColor.disableOn = function(d) return not d.healPredictionEnabled end + end + + local blendOptions = { BLEND= L["Normal (BLEND)"], ADD= L["Additive (ADD)"] } + local blendDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Blend Mode"], blendOptions, db, "healPredictionBlendMode", function() DF:UpdateAllFrames() end), 55) + blendDropdown.disableOn = function(d) return not d.healPredictionEnabled end + + Add(settingsGroup, nil, 1) + + + -- ===== FLOATING POSITION GROUP (Column 1, conditional) ===== + local floatingGroup = GUI:CreateSettingsGroup(self.child, 280) + floatingGroup:AddWidget(GUI:CreateHeader(self.child, L["Floating Bar Position"]), 40) + + local orientOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } + local orientDropdown = floatingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "healPredictionOrientation", function() DF:UpdateAllFrames() end), 55) + orientDropdown.disableOn = function(d) return not d.healPredictionEnabled end + + local revFill = floatingGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "healPredictionReverse", function() DF:UpdateAllFrames() end), 30) + revFill.disableOn = function(d) return not d.healPredictionEnabled end + + local widthSlider = floatingGroup:AddWidget(GUI:CreateSlider(self.child, L["Width"], 10, 200, 1, db, "healPredictionWidth", nil, function() DF:UpdateAllFrames() end, true), 55) + widthSlider.disableOn = function(d) return not d.healPredictionEnabled end + + local heightSlider = floatingGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 30, 1, db, "healPredictionHeight", nil, function() DF:UpdateAllFrames() end, true), 55) + heightSlider.disableOn = function(d) return not d.healPredictionEnabled end + + floatingGroup.hideOn = function(d) return d.healPredictionMode ~= "FLOATING" end + Add(floatingGroup, nil, 1) + + -- ===== FLOATING ANCHOR GROUP (Column 2, conditional) ===== + local anchorGroup = GUI:CreateSettingsGroup(self.child, 280) + anchorGroup:AddWidget(GUI:CreateHeader(self.child, L["Floating Bar Anchor"]), 40) + + local anchorOptions = { + CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], + TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], + } + local anchorDropdown = anchorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "healPredictionAnchor", function() DF:UpdateAllFrames() end), 55) + anchorDropdown.disableOn = function(d) return not d.healPredictionEnabled end + + local xSlider = anchorGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "healPredictionX", nil, function() DF:UpdateAllFrames() end, true), 55) + xSlider.disableOn = function(d) return not d.healPredictionEnabled end + + local ySlider = anchorGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "healPredictionY", nil, function() DF:UpdateAllFrames() end, true), 55) + ySlider.disableOn = function(d) return not d.healPredictionEnabled end + + local bgColorPicker = anchorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "healPredictionBackgroundColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) + bgColorPicker.disableOn = function(d) return not d.healPredictionEnabled end + + anchorGroup.hideOn = function(d) return d.healPredictionMode ~= "FLOATING" end + Add(anchorGroup, nil, 2) + end) + + -- ======================================== + -- CATEGORY: Text + -- ======================================== + CreateCategory("text", L["Text"]) + + -- LEGACY-TEXT-CLEANUP (v4.4.x): Name/Health/Status built-in text settings are + -- replaced by the Text Designer. These three pages are hidden via `if false` + -- (not deleted, so they can be restored). Remove this block, the legacy text + -- render path (see DF:IsLegacyTextHidden in Frames/Core.lua), and the legacy + -- *Text* defaults in Config.lua in a future release once the Text Designer + -- fully supersedes them. + + -- Text > Text Designer + -- See spec at docs/superpowers/specs/2026-05-22-text-designer-phase1-design.md + local pageTextDesigner = CreateSubTab("text", "text_designer", L["Text Designer"]) + BuildPage(pageTextDesigner, function(self, db, Add, AddSpace, AddSyncPoint) + if DF.BuildTextDesignerPage then + DF.BuildTextDesignerPage(GUI, self, db) + end + end) + + -- ======================================== + -- CATEGORY: Auras + -- ======================================== + CreateCategory("auras", L["Auras"]) + + -- Auras > Aura Filters (the merged page: pick filters AND edit their spells) + -- + -- Keeps the FAMILIAR NAME while the page id stays "auras_filterdesigner". That is + -- deliberate: every cross-link, Search entry and _fdSelect* entry point already + -- targets that id, so relabelling costs nothing whereas renaming the id would mean + -- chasing all of them. The old "auras_filters" page is gone -- its filter switches + -- moved into this page's left-hand list, its ordering and duration controls onto + -- the Buffs and Debuffs pages. + -- + -- SECTION KEYS, spelled out rather than stemmed. Ownership is longest-prefix-wins + -- (DF:SectionOwnsKey), so: + -- * "buffFilterSelection" beats the Buffs page's broad "buff" + -- * "debuffFilter" / "debuffBlacklist" beat the Debuffs page's broad "debuff" + -- * the three scope keys are listed INDIVIDUALLY because the sort keys that just + -- moved away (directBuffSortOrder, directDebuffSort*) share the "directBuff" / + -- "directDebuff" stems. A stem here would drag them back, and this page's + -- Copy/Reset would silently reach into the bar pages. + local pageFilterDesigner = CreateSubTab("auras", "auras_filterdesigner", L["Aura Filters"]) + BuildPage(pageFilterDesigner, function(self, db, Add, AddSpace, AddSyncPoint) + Add(CreateCopyButton(self.child, { + "buffFilterSelection", "debuffFilter", "debuffBlacklist", + "directBuffShowAll", "directBuffOnlyMine", "directDebuffShowAll", + }, L["Aura Filters"], "auras_filterdesigner"), 25, 2) + if DF.BuildFilterDesignerPage then DF.BuildFilterDesignerPage(GUI, self, db) end + + -- See Also, after the page's own content. This page positions its panels + -- absolutely and reports its height through an Add()ed spacer, so anything + -- Add()ed afterwards lands below them -- which is where a footer belongs. + -- + -- It also does real work here rather than being decoration: the four links + -- ARE the answer to "what uses these filters". Naming the consumers as + -- somewhere you can go beats another sentence explaining that they exist, + -- and this was the only page under Auras without a See Also bar. + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_buffs", label = L["Buffs"]}, + {pageId = "auras_debuffs", label = L["Debuffs"]}, + {pageId = "auras_defensiveicon", label = L["Defensive Icon"]}, + {pageId = "auras_auradesigner", label = L["Aura Designer"]}, + }), 30, "both") + end) + + -- Auras > Aura Designer + local pageAuraDesigner = CreateSubTab("auras", "auras_auradesigner", L["Aura Designer"]) + BuildPage(pageAuraDesigner, function(self, db, Add, AddSpace, AddSyncPoint) + if DF.BuildAuraDesignerPage then + DF.BuildAuraDesignerPage(GUI, self, db) + end + end) + + -- Auras > Aura Blacklist: RETIRED as a standalone page. The debuff blacklist + -- now lives inside the Filter Designer (Debuffs > Blacklist) — one home for + -- all per-spell aura control. Backend unchanged (AuraBlacklist/Config.lua + + -- Features/Auras.lua applyDebuffBlacklist); stored data carries over. + + -- Auras > Buffs (combined Layout + Appearance with collapsible sections) + local pageBuffs = CreateSubTab("auras", "auras_buffs", L["Buffs"]) + DF._SetupGUIPagesPart4(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) +end \ No newline at end of file diff --git a/GUI/Pages/Frames.lua b/GUI/Pages/Frames.lua new file mode 100644 index 00000000..0757dd77 --- /dev/null +++ b/GUI/Pages/Frames.lua @@ -0,0 +1,1896 @@ +-- Part 2 of the settings pages, split from Options.lua. +-- The parts run as a chain so the pages build in their original order. +local addonName, DF = ... +local format = string.format +function DF._SetupGUIPagesPart2(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) + local pageGlobalFonts = CreateSubTab("general", "general_fonts", L["Global Fonts"]) + BuildPage(pageGlobalFonts, function(self, db, Add, AddSpace, AddSyncPoint) + Add(CreateCopyButton(self.child, {"fontShadow"}, L["Global Fonts"], "general_fonts"), 25, 2) + -- Initialize temp storage for selections (persists during session) + if not DF.GlobalFontTemp then + DF.GlobalFontTemp = { + font = db.nameFont or "Fonts\\FRIZQT__.TTF", + outline = db.nameTextOutline or "OUTLINE", + } + end + + -- ===== FONT SELECTION GROUP (Column 1) ===== + local fontSelectGroup = GUI:CreateSettingsGroup(self.child, 280) + fontSelectGroup:AddWidget(GUI:CreateHeader(self.child, L["Global Font Settings"]), 40) + fontSelectGroup:AddWidget(GUI:CreateLabel(self.child, L["Set a font and outline style, then click Apply to update ALL text elements."], 250), 40) + + fontSelectGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], DF.GlobalFontTemp, "font", function() end), 55) + + fontSelectGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], DF.GlobalFontTemp, "outline", function() end), 55) + fontSelectGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], DF.GlobalFontTemp, "outline", function() end), 30) + + -- Themed Apply button + local applyBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") + GUI:StyleButton(applyBtn, { width = 120, height = 28, text = L["Apply to All"] }) + applyBtn.text = applyBtn.Text + applyBtn:SetScript("OnClick", function() + local font = DF.GlobalFontTemp.font + local outline = DF.GlobalFontTemp.outline + + -- Clear font family cache so new fonts are created + if DF.ClearFontCache then DF:ClearFontCache() end + + -- Apply to all font settings + db.nameFont = font; db.nameTextOutline = outline + db.healthFont = font; db.healthTextOutline = outline + db.statusTextFont = font; db.statusTextOutline = outline + db.buffStackFont = font; db.buffStackOutline = outline + db.buffDurationFont = font; db.buffDurationOutline = outline + db.debuffStackFont = font; db.debuffStackOutline = outline + db.debuffDurationFont = font; db.debuffDurationOutline = outline + db.petNameFont = font; db.petNameFontOutline = outline + db.petHealthFont = font; db.petHealthFontOutline = outline + db.personalTargetedSpellDurationFont = font; db.personalTargetedSpellDurationOutline = outline + db.targetedListFont = font; db.targetedListFontOutline = outline + db.defensiveIconDurationFont = font; db.defensiveIconDurationOutline = outline + db.statusIconFont = font; db.statusIconFontOutline = outline + -- AFK timer text inherits the status-icon font; clear any per-timer + -- override so it follows the freshly-applied global font. + db.afkIconTimerFont = nil; db.afkIconTimerOutline = nil + if db.groupLabelFont ~= nil then + db.groupLabelFont = font; db.groupLabelOutline = outline + end + -- Aura Designer global defaults + clear per-instance overrides. + -- AD config now lives in the preset this mode uses, not inline. + -- BASE resolver: "apply font globally" edits the user's base + -- preset — with a runtime auto-layout active, the ACTIVE resolver + -- would mutate the layout's preset instead (editor model is BASE). + local _adMode = (db == DF.db.raid) and "raid" or "party" + local _adCfg = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(_adMode)) + or (DF.GetModeAuraDesigner and DF:GetModeAuraDesigner(_adMode)) + if _adCfg then + if _adCfg.defaults then + local adDefaults = _adCfg.defaults + adDefaults.durationFont = font; adDefaults.durationOutline = outline + adDefaults.stackFont = font; adDefaults.stackOutline = outline + end + -- Clear per-instance font overrides so all indicators inherit global + if _adCfg.auras then + for _, auraCfg in pairs(_adCfg.auras) do + if auraCfg.indicators then + for _, inst in ipairs(auraCfg.indicators) do + inst.durationFont = nil; inst.durationOutline = nil + inst.stackFont = nil; inst.stackOutline = nil + end + end + end + end + end + + -- Text Designer text elements: the legacy name/health/status + -- fontstrings are retired (IsLegacyTextHidden), so the visible + -- name/health/status text now comes from the Text Designer. Drive + -- its elements too (BASE preset, matching the AD block above) so + -- "Apply to All" actually changes that text. + local _tdMode = (db == DF.db.raid) and "raid" or "party" + local _tdCfg = (DF.GetModeBaseTextDesigner and DF:GetModeBaseTextDesigner(_tdMode)) + or (DF.GetModeTextDesigner and DF:GetModeTextDesigner(_tdMode)) + if _tdCfg then + if _tdCfg.elements then + for _, el in ipairs(_tdCfg.elements) do + el.font = font; el.outline = outline + end + end + if _tdCfg.globalDefaults then + _tdCfg.globalDefaults.font = font + _tdCfg.globalDefaults.outline = outline + end + end + + DF:UpdateAllFrames() + if GUI.SelectedMode == "raid" and DF.UpdateRaidLayout then DF:UpdateRaidLayout() end + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + if (DF.testMode or DF.raidTestMode) and DF.UpdateAllTestTargetedSpell then DF:UpdateAllTestTargetedSpell() end + if DF.UpdateTestPersonalTargetedSpells then DF:UpdateTestPersonalTargetedSpells() end + if DF.UpdateTargetedListLayout then DF:UpdateTargetedListLayout() end + if DF.UpdateAllFramesStatusIcons then DF:UpdateAllFramesStatusIcons() end + + -- Refresh test frames to apply new fonts + if DF.RefreshTestFrames then DF:RefreshTestFrames() end + + -- Force Aura Designer to re-apply indicators with new fonts + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + -- Also refresh the AD options preview if visible + if DF.AuraDesigner_RefreshPage then DF:AuraDesigner_RefreshPage() end + + -- Text Designer owns the live name/health/status text — re-render it. + if DF.TextDesigner and DF.TextDesigner.Preview and DF.TextDesigner.Preview.RefreshLiveFrames then + DF.TextDesigner.Preview:RefreshLiveFrames() + end + + DF:Say("Applied global font settings to all text elements.") + end) + fontSelectGroup:AddWidget(applyBtn, 35) + + fontSelectGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Crisp Font Rendering (SDF)"], DF.db, "fontSlug", function() + if DF.ClearFontCache then DF:ClearFontCache() end + DF:UpdateAllFrames() + if GUI.SelectedMode == "raid" and DF.UpdateRaidLayout then DF:UpdateRaidLayout() end + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + if DF.RefreshTestFrames then DF:RefreshTestFrames() end + end), 30) + fontSelectGroup:AddWidget(GUI:CreateLabel(self.child, L["Renders text with signed-distance-field smoothing for sharper edges at any size. Applies to None and Outline styles only (not Monochrome, Thick, or Shadow)."], 250), 50) + + Add(fontSelectGroup, nil, 1) + + -- ===== SHADOW SETTINGS GROUP (Column 1) ===== + local shadowGroup = GUI:CreateSettingsGroup(self.child, 280) + shadowGroup:AddWidget(GUI:CreateHeader(self.child, L["Shadow Settings"]), 40) + shadowGroup:AddWidget(GUI:CreateLabel(self.child, L["These settings apply when using 'Shadow' outline style. Use larger offsets for more dramatic shadows."], 250), 40) + + local function UpdateShadowSettings() + -- Full update on release + if DF.ClearFontCache then DF:ClearFontCache() end + DF:UpdateAllFrames() + if GUI.SelectedMode == "raid" and DF.UpdateRaidLayout then DF:UpdateRaidLayout() end + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + -- UpdateAllFrames doesn't reach the pinned pool — re-font it too. + if DF.RefreshPinnedFonts then DF:RefreshPinnedFonts() end + -- Re-render the Text Designer overlay (the visible text) so its shadow + -- updates on pinned + live frames too. + if DF.TextDesigner and DF.TextDesigner.Preview and DF.TextDesigner.Preview.RefreshLiveFrames then + DF.TextDesigner.Preview:RefreshLiveFrames() + end + end + + local function LightweightShadowUpdate() + if DF.LightweightUpdateFontShadows then DF:LightweightUpdateFontShadows() end + end + + shadowGroup:AddWidget(GUI:CreateSlider(self.child, L["Shadow X Offset"], -10, 10, 0.5, db, "fontShadowOffsetX", UpdateShadowSettings, LightweightShadowUpdate), 50) + shadowGroup:AddWidget(GUI:CreateSlider(self.child, L["Shadow Y Offset"], -10, 10, 0.5, db, "fontShadowOffsetY", UpdateShadowSettings, LightweightShadowUpdate), 50) + shadowGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Shadow Color"], db, "fontShadowColor", true, UpdateShadowSettings, LightweightShadowUpdate, true), 40) + + Add(shadowGroup, nil, 1) + + -- ===== AFFECTED ELEMENTS GROUP (Column 2) ===== + local infoGroup = GUI:CreateSettingsGroup(self.child, 280) + infoGroup:AddWidget(GUI:CreateHeader(self.child, L["Affected Elements"]), 40) + infoGroup:AddWidget(GUI:CreateLabel(self.child, L["• Text Designer (Name, Health, Status & custom text)\n• Buff Stack & Duration\n• Debuff Stack & Duration\n• Pet Frame Text\n• Targeted Spell Duration\n• Defensive Icon Duration\n• All Icon Text (Res, Summon, etc.)\n• Group Labels (Raid)\n• Targeted List\n• Personal Targeted Spell\n• Aura Designer Indicators\n• Pinned Frames"], 250), 235) + infoGroup:AddWidget(GUI:CreateNote(self.child, L["Font sizes are not changed. Adjust sizes in each element's page."], {tone = "caution", prefix = "Note", width = 250}), 40) + Add(infoGroup, nil, 2) + end) + + -- General > Group Labels (Raid only, group-based layout only) + local pageGroupLabels = CreateSubTab("general", "general_labels", L["Group Labels"]) + BuildPage(pageGroupLabels, function(self, db, Add, AddSpace, AddSyncPoint) + Add(CreateCopyButton(self.child, {"groupLabel"}, L["Group Labels"], "general_labels"), 25, 2) + -- The dependent groups stay hidden under mode/variant gating (not raid, + -- or flat layout), but when visible they GREY OUT (disabled-in-place) + -- while the Enable toggle is off rather than vanishing. + local function HideGroupLabelOptions() + return GUI.SelectedMode ~= "raid" or not db.raidUseGroups + end + local function DisableGroupLabelOptions(d) + return not d.groupLabelEnabled + end + + local function UpdateLabels() + if DF.UpdateRaidGroupLabels then DF:UpdateRaidGroupLabels() end + end + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Raid Group Labels"]), 40) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Display labels above or beside each raid group."], 250), 25) + local groupLabelEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Group Labels"], db, "groupLabelEnabled", function() + UpdateLabels() + self:RefreshStates() + end), 30) + groupLabelEnable.keepEnabled = true + settingsGroup.hideOn = HideGroupLabelOptions + Add(settingsGroup, nil, 1) + + -- ===== TEXT FORMAT GROUP (Column 1) ===== + local formatGroup = GUI:CreateSettingsGroup(self.child, 280) + formatGroup:AddWidget(GUI:CreateHeader(self.child, L["Text Format"]), 40) + + local formatOptions = { + ["GROUP_NUM"] = L["Group 1"], + ["SHORT"] = L["G1"], + ["NUM_ONLY"] = L["1"], + ["ROMAN"] = L["I, II, III..."], + } + formatGroup:AddWidget(GUI:CreateDropdown(self.child, L["Label Format"], formatOptions, db, "groupLabelFormat", UpdateLabels), 55) + formatGroup.hideOn = HideGroupLabelOptions + formatGroup.disableChildrenOn = DisableGroupLabelOptions + Add(formatGroup, nil, 2) + + -- ===== FONT GROUP (Column 1) ===== + local fontGroup = GUI:CreateSettingsGroup(self.child, 280) + fontGroup:AddWidget(GUI:CreateHeader(self.child, L["Font Settings"]), 40) + fontGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "groupLabelFont", UpdateLabels), 55) + fontGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 8, 24, 1, db, "groupLabelFontSize", UpdateLabels, function() DF:LightweightUpdateGroupLabels() end, true), 55) + + fontGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "groupLabelOutline", UpdateLabels), 55) + fontGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "groupLabelOutline", UpdateLabels), 30) + fontGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Label Color"], db, "groupLabelColor", true, UpdateLabels, function() DF:LightweightUpdateGroupLabelColor() end, true), 35) + fontGroup.hideOn = HideGroupLabelOptions + fontGroup.disableChildrenOn = DisableGroupLabelOptions + Add(fontGroup, nil, 2) + + -- ===== POSITION GROUP (Column 2) ===== + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + + local positionOptions = { + ["START"] = L["Start of Group"], + ["CENTER"] = L["Center of Group"], + ["END"] = L["End of Group"], + } + positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Label Position"], positionOptions, db, "groupLabelPosition", UpdateLabels), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "groupLabelOffsetX", UpdateLabels, function() DF:LightweightUpdateGroupLabels() end, true), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "groupLabelOffsetY", UpdateLabels, function() DF:LightweightUpdateGroupLabels() end, true), 55) + positionGroup:AddWidget(GUI:CreateLabel(self.child, L["Start: Above/left of groups.\nCenter: Middle of the group.\nEnd: Below/right of groups."], 250), 50) + positionGroup.hideOn = HideGroupLabelOptions + positionGroup.disableChildrenOn = DisableGroupLabelOptions + Add(positionGroup, nil, 1) + + -- Party mode message + local partyMsg = Add(GUI:CreateLabel(self.child, L["Group labels are only available for raid frames.\n\nSwitch to Raid mode using the toggle at the top\nof the settings panel to configure group labels."], 400), 80, "both") + partyMsg.hideOn = function() return GUI.SelectedMode == "raid" end + + -- Flat mode message + local flatMsg = Add(GUI:CreateNote(self.child, L["Group labels are not available in Flat Grid layout.\n\nEnable 'Use Group-Based Layout' in Frame settings\nto use group labels."], {tone = "caution", width = 400}), 80, "both") + flatMsg.hideOn = function() return GUI.SelectedMode ~= "raid" or db.raidUseGroups end + end) + + -- General > Pinned Frames + local pagePinnedFrames = CreateSubTab("general", "general_pinnedframes", L["Pinned Frames"]) + BuildPage(pagePinnedFrames, function(self, db, Add, AddSpace, AddSyncPoint) + Add(CreateCopyButton(self.child, {"pinnedFrames"}, L["Pinned Frames"], "general_pinnedframes"), 25, 2) + -- Constants — mirror the runtime cap so the editor builds exactly as many + -- tab buttons as the backend allows (sets beyond the current count are hidden). + local HIGHLIGHT_MAX_SETS = (DF.PinnedFrames and DF.PinnedFrames.MAX_SETS) or 5 + + -- Initialize pinnedFrames in db if needed + if not db.pinnedFrames then + db.pinnedFrames = { + disableInPvP = true, -- mode-level: dormant in arena/battlegrounds + sets = { + [1] = { + enabled = false, name = "Pinned 1", players = {}, + growDirection = "HORIZONTAL", unitsPerRow = 5, + horizontalSpacing = 2, verticalSpacing = 2, scale = 1.0, + position = { point = "CENTER", x = 0, y = 200 }, + showLabel = false, + autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, + keepOfflinePlayers = false, + }, + [2] = { + enabled = false, name = "Pinned 2", players = {}, + growDirection = "HORIZONTAL", unitsPerRow = 5, + horizontalSpacing = 2, verticalSpacing = 2, scale = 1.0, + position = { point = "CENTER", x = 0, y = -200 }, + showLabel = false, + autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, + keepOfflinePlayers = false, + }, + }, + } + end + + -- Migration: mode-level disableInPvP (existing profiles predate it). nil is + -- treated as true by the runtime gate, but seed it so the toggle reads right. + if db.pinnedFrames.disableInPvP == nil then db.pinnedFrames.disableInPvP = true end + + -- Migration: add new options to existing sets + for i = 1, #db.pinnedFrames.sets do + local set = db.pinnedFrames.sets[i] + if set then + if set.autoAddTanks == nil then set.autoAddTanks = false end + if set.autoAddHealers == nil then set.autoAddHealers = false end + if set.autoAddDPS == nil then set.autoAddDPS = false end + -- Match Config's default (false). Post-fix, manual pins always + -- persist (CleanOfflinePlayers spares manualPlayers); this toggle + -- only keeps AUTO-added members after they go offline / leave. + if set.keepOfflinePlayers == nil then set.keepOfflinePlayers = false end + if set.columnAnchor == nil then set.columnAnchor = "START" end + if set.frameAnchor == nil then set.frameAnchor = "START" end + -- CENTER anchor was dropped (never truly centred the frames; it + -- rendered as START). Normalise so the dropdown has a valid value. + if set.columnAnchor == "CENTER" then set.columnAnchor = "START" end + if set.frameAnchor == "CENTER" then set.frameAnchor = "START" end + -- set.locked retired (global lock only); strip the dead field. + set.locked = nil + if set.showLabel == nil then set.showLabel = false end + if set.players == nil then set.players = {} end + if set.manualPlayers == nil then set.manualPlayers = {} end + if set.frameType == nil then set.frameType = "player" end + if set.testCount == nil then set.testCount = 3 end + end + end + + -- Current active tab (persist across page refreshes so switching tabs + -- between sets with different frameTypes — which calls RefreshCurrentPage — + -- doesn't snap back to tab 1) + pagePinnedFrames.persistedTab = pagePinnedFrames.persistedTab or 1 + -- Clamp into the live set count — a set may have been removed since this + -- was last persisted (or in the other mode), so never address a nil set. + local setCount = #db.pinnedFrames.sets + if pagePinnedFrames.persistedTab > setCount then pagePinnedFrames.persistedTab = setCount end + if pagePinnedFrames.persistedTab < 1 then pagePinnedFrames.persistedTab = 1 end + local activeHighlightTab = pagePinnedFrames.persistedTab + -- Sub-tab within a set's editor: + -- "setup" = Settings + Frame Type (always present) + -- "appearance" = Frame Style + Layout (always present) + -- "members" = Unit Selection + Auto-Populate (player sets only) + -- Persisted across page rebuilds; defaults to Setup. Clamped below so a + -- persisted "members" never sticks on a boss set (which has no Members tab). + pagePinnedFrames.persistedSubTab = pagePinnedFrames.persistedSubTab or "setup" + local activeSubTab = pagePinnedFrames.persistedSubTab + local tabButtons = {} + local controlsToRefresh = {} + -- Forward refs assigned in the tab-strip build below; RefreshTabs reads them. + local tabContainer, addSetBtn, setMeta + + -- Invalidate + rebuild the pinned page (after add/remove the whole editor + -- must re-render with the new tab count + the active set's widgets). + local function RebuildPinnedPage() + if GUI.InvalidatePage then GUI:InvalidatePage(GUI.CurrentPageName) end + if GUI.RefreshCurrentPage then GUI.RefreshCurrentPage() end + end + + local function DoAddSet() + if not DF.PinnedFrames then return end + -- Target the mode currently being edited (party/raid are independent). + local newIndex = DF.PinnedFrames:AddSet(GUI.SelectedMode) + if newIndex then + pagePinnedFrames.persistedTab = newIndex -- jump to the new set + RebuildPinnedPage() + end + end + + local function DoRemoveSet(idx) + if not DF.PinnedFrames then return end + -- Capture the edited mode at click time (robust if the GUI mode changes + -- while the confirm popup is open). Party/raid set lists are independent. + -- The closure replaces what used to travel as the StaticPopup `data` + -- payload, which is the field whose behaviour varies across clients. + local mode = GUI.SelectedMode + DF:ShowPopupAlert({ + title = L["Remove Pinned Set"], + message = L["Remove this pinned set? Its members and settings will be lost."], + buttons = { + { + label = L["Remove"], + onClick = function() + if DF.PinnedFrames:RemoveSet(idx, mode) then + if pagePinnedFrames.persistedTab > 1 then + pagePinnedFrames.persistedTab = pagePinnedFrames.persistedTab - 1 + end + RebuildPinnedPage() + end + end, + }, + { label = L["Cancel"] }, + }, + }) + end + + local function GetCurrentSet() + return db.pinnedFrames.sets[activeHighlightTab] + end + + local function IsCurrentBossMode() + local s = GetCurrentSet() + return s and s.frameType == "friendlyBoss" + end + + -- A pinned set that is not enabled shows ONLY its Enable toggle; everything + -- else (the other Setup controls, Frame Type, and the Appearance/Members + -- tabs) is hidden until the set is enabled. + local function PinnedSetDisabled() + local s = GetCurrentSet() + return not (s and s.enabled) + end + + -- Forward-declared: the set-tab OnClick (defined below, before this is + -- assigned) re-runs it when you switch sets so the new set's enabled state + -- re-drives the sub-tab visibility. + local RefreshSubTabs + + local function RefreshControls() + for _, ctrl in ipairs(controlsToRefresh) do + if ctrl.Refresh then ctrl:Refresh() end + end + end + + -- Tab metrics, shared by RefreshTabs (width) and tab creation (label inset): + -- the label must clear the status pip on the left and the × on the right. + local TAB_LABEL_LEFT, TAB_LABEL_RIGHT = 22, 24 + local TAB_MIN_W, TAB_MAX_W = 96, 160 + local function RefreshTabs() + local count = #db.pinnedFrames.sets + -- Adaptive tab width: share the strip so a couple of sets get roomy + -- tabs, clamped 120-160 so four still fit. Reserve room for the meter + -- (right) and the + Add button (shown when below the cap). Use the + -- fixed design width (NOT GetWidth) so the width is deterministic and + -- can't jump on a page rebuild where GetWidth() isn't settled yet. + local TAB_GAP = 4 -- small gap so the (now filled) cells read as distinct tabs + -- Lay out against the container's CURRENT width so the strip adapts when + -- the addon frame is resized narrow (fall back to the design width until + -- the first layout pass settles GetWidth). Re-flows via OnSizeChanged. + local cw = tabContainer:GetWidth() or 560 + if cw < 100 then cw = 560 end + local stripW = cw - 84 + if count < HIGHLIGHT_MAX_SETS then stripW = stripW - 70 end + -- Per-tab cap from the even share so the whole strip still fits when the + -- window is narrow; tabs otherwise HUG their label (see naturalW below) + -- rather than every tab stretching to a fixed width. + local maxPerTab = math.min(TAB_MAX_W, math.floor((stripW - (count - 1) * TAB_GAP) / math.max(count, 1))) + if maxPerTab < TAB_MIN_W then maxPerTab = TAB_MIN_W end + + local x = 0 -- running left offset; tabs are no longer uniform-width + for i, tab in ipairs(tabButtons) do + local set = db.pinnedFrames.sets[i] + if not set then + -- Tab button beyond the current set count — hide it. + tab:Hide() + else + tab:Show() + local isActive = (i == activeHighlightTab) + tab:SetActive(isActive) -- underline + accent/dim label + + -- Build the label first so the tab can be sized to it. + local displayName = set.name + if displayName == L["Pinned"] .. " " .. i or displayName == "" then displayName = L["Pinned"] .. " " .. i end + -- Show the pinned member count on the tab (player sets only — boss + -- sets auto-track boss1-8 and have no member list). + if set.frameType ~= "friendlyBoss" then + displayName = displayName .. " (" .. #(set.players or {}) .. ")" + end + tab.text:SetText(displayName) + + -- Hug the label (clamped): short names get a tight tab, long names + -- truncate at the cap instead of every tab being max width. + local naturalW = math.ceil(tab.text:GetStringWidth()) + TAB_LABEL_LEFT + TAB_LABEL_RIGHT + local tabW = math.max(TAB_MIN_W, math.min(maxPerTab, naturalW)) + tab:SetWidth(tabW) + tab:SetPoint("LEFT", tabContainer, "LEFT", x, 0) + x = x + tabW + TAB_GAP + + -- On/off pip, independent of the selected-tab highlight above. + if tab.statusDot then + if set.enabled then + tab.statusDot:SetVertexColor(0.30, 0.82, 0.38) -- green = enabled + else + tab.statusDot:SetVertexColor(0.32, 0.32, 0.32) -- grey = disabled + end + end + -- Remove (×) only on the active tab, and only when more than one + -- set exists (the last set can't be removed). Keeps the strip clean. + if tab.removeBtn then + if isActive and count > 1 then tab.removeBtn:Show() else tab.removeBtn:Hide() end + end + end + end + -- "+ Add set" sits just after the last set; hidden at the cap. + if addSetBtn then + if count < HIGHLIGHT_MAX_SETS then + addSetBtn:ClearAllPoints() + addSetBtn:SetPoint("LEFT", tabContainer, "LEFT", x + 4, 0) + addSetBtn:Show() + else + addSetBtn:Hide() + end + end + -- Count + active-set meter (each enabled set is a live secure header). + if setMeta then + local active = 0 + for _, s in ipairs(db.pinnedFrames.sets) do if s.enabled then active = active + 1 end end + setMeta:SetText(count .. "/" .. HIGHLIGHT_MAX_SETS .. " " .. active .. " " .. L["active"]) + if active >= 4 then setMeta:SetTextColor(0.95, 0.7, 0.2) else setMeta:SetTextColor(0.45, 0.45, 0.45) end + end + end + + -- ===== HEADER GROUP (full width) ===== + local headerGroup = GUI:CreateSettingsGroup(self.child, 560) + headerGroup:AddWidget(GUI:CreateHeader(self.child, L["Pinned Frames"]), 40) + -- Auto-size the description's slot to the actual wrapped text height so the + -- box hugs the text at every width (no fixed bottom padding, no truncation). + -- GetStringHeight returns a stale single-line value right after a width + -- change, so we measure on a DEFERRED frame (OnSizeChanged -> C_Timer) once + -- the FontString has re-wrapped, then update the group's slot height and + -- bubble a relayout up to the page. Mirrors GUI:CreateInfoBanner. + local pinnedDescLabel = GUI:CreateLabel(self.child, L["Create separate frame groups to pin specific players like tanks, healers, or key raid members, or to track NPC frames. Add players using the Members tab."], 530) + do + local descFS + for _, r in ipairs({ pinnedDescLabel:GetRegions() }) do + if r.GetStringHeight then descFS = r break end + end + local applying, lastW = false, nil + local function ApplyDescHeight() + if applying or not descFS or not pinnedDescLabel:IsVisible() then return end + local g = pinnedDescLabel.settingsGroup + if not g then return end + local desired = math.ceil(descFS:GetStringHeight() or 18) + 6 + for _, entry in ipairs(g.groupChildren) do + if entry.widget == pinnedDescLabel then + if entry.height ~= desired then + applying = true + entry.height = desired + g:LayoutChildren() + local p = g:GetParent() -- bubble so the page's column layout sees the new height + while p do + if type(p.RefreshStates) == "function" and p.children then p:RefreshStates() break end + p = p:GetParent() + end + applying = false + end + break + end + end + end + local function ScheduleApply() + if C_Timer and C_Timer.After then C_Timer.After(0, ApplyDescHeight) else ApplyDescHeight() end + end + pinnedDescLabel:SetScript("OnSizeChanged", function(_, w) + if w == lastW then return end -- only width changes affect wrap height + lastW = w + ScheduleApply() + end) + -- Re-measure when the page surfaces (GetStringHeight is unreliable while + -- hidden) and once on build in case the width never changes. + pinnedDescLabel:SetScript("OnShow", function() lastW = nil ScheduleApply() end) + ScheduleApply() + end + -- Initial slot fits 2 lines; the deferred measure grows/shrinks it to fit. + headerGroup:AddWidget(pinnedDescLabel, 34) + Add(headerGroup, nil, "both") + + -- Tab container + tabContainer = CreateFrame("Frame", nil, self.child) + tabContainer:SetSize(560, 32) + Add(tabContainer, 32, "both") + + -- Baseline track running under the whole tab strip (at the tabs' bottom + -- edge). The active tab's accent underline sits on this, giving the + -- AD-style tab-bar effect as you switch between tabs. + local tabBaseline = tabContainer:CreateTexture(nil, "ARTWORK") + tabBaseline:SetTexture("Interface\\Buttons\\WHITE8x8") + tabBaseline:SetHeight(1) + tabBaseline:SetPoint("BOTTOMLEFT", tabContainer, "BOTTOMLEFT", 0, 1) + tabBaseline:SetPoint("BOTTOMRIGHT", tabContainer, "BOTTOMRIGHT", 0, 1) + local tabBaseClr = (GUI.Colors and GUI.Colors.border) or { r = 0.25, g = 0.25, b = 0.25 } + tabBaseline:SetColorTexture(tabBaseClr.r, tabBaseClr.g, tabBaseClr.b, 0.5) + + -- Re-flow the strip when the addon frame is resized so the tabs share the + -- current width instead of overflowing into the meter. RefreshTabs is + -- nil-guarded for the buttons it touches, so an early fire is harmless. + tabContainer:SetScript("OnSizeChanged", function() RefreshTabs() end) + + for i = 1, HIGHLIGHT_MAX_SETS do + local tab = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") + tab:SetSize(120, 30) + tab:SetPoint("LEFT", tabContainer, "LEFT", (i - 1) * 120, 0) + -- Underline tab on the shared styler; RefreshTabs drives SetActive. + GUI:StyleButton(tab, { tab = true, text = L["Pinned"] .. " " .. i, font = "DFFontHighlight" }) + tab.text = tab.Text -- RefreshTabs sets the dynamic name (with count) here + -- Pin the label between the status pip (left) and the × (right) and + -- ellipsis-truncate, so a long name can't bleed into the next + -- (now edge-to-edge) tab and never overlaps the pip/×. + tab.text:ClearAllPoints() + tab.text:SetPoint("LEFT", tab, "LEFT", TAB_LABEL_LEFT, 0) + tab.text:SetPoint("RIGHT", tab, "RIGHT", -TAB_LABEL_RIGHT, 0) + tab.text:SetJustifyH("CENTER") + tab.text:SetWordWrap(false) + -- Status pip on the left: green = set enabled, dim grey = disabled. + -- Independent of the active-tab highlight (border + text colour), so a + -- set's on/off state is visible whether or not it's the selected tab. + tab.statusDot = tab:CreateTexture(nil, "OVERLAY") + tab.statusDot:SetTexture("Interface\\Buttons\\WHITE8x8") + tab.statusDot:SetSize(7, 7) + tab.statusDot:SetPoint("LEFT", tab, "LEFT", 8, 0) + -- Remove (×) button on the right — shown by RefreshTabs only on the + -- active tab when more than one set exists. Confirms before removing. + tab.removeBtn = GUI:CreateCloseButton(tab, { size = 16, onClick = function() DoRemoveSet(i) end }) + tab.removeBtn:SetPoint("RIGHT", tab, "RIGHT", -4, 0) + tab.removeBtn:Hide() + tab:SetScript("OnClick", function() + local oldSet = GetCurrentSet() + local oldType = oldSet and oldSet.frameType + activeHighlightTab = i + pagePinnedFrames.persistedTab = i + local newSet = GetCurrentSet() + local newType = newSet and newSet.frameType + RefreshTabs() + if oldType ~= newType and GUI.RefreshCurrentPage then + -- Frame type differs between tabs — invalidate cache so the page + -- rebuilds with the correct set of widgets for the new frame type. + if GUI.InvalidatePage then GUI:InvalidatePage(GUI.CurrentPageName) end + GUI.RefreshCurrentPage() + else + RefreshControls() + -- The newly-selected set may differ in enabled state, so re-run the + -- disabled-gating: sub-tab visibility + per-control hideOn reflow. + if RefreshSubTabs then RefreshSubTabs() end + self:RefreshStates() + if GUI.RefreshAllOverrideIndicators then GUI.RefreshAllOverrideIndicators() end + end + end) + tabButtons[i] = tab + end + + -- "+ Add set" button — RefreshTabs positions it after the last set and + -- hides it at the cap. Adds a (disabled) set to every mode + jumps to it. + addSetBtn = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") + addSetBtn:SetSize(64, 28) + -- Ghost action: a faint cell (matching the tabs) with an accent "+ Add" + -- that brightens on hover — consistent with the strip, quiet add action. + GUI:StyleButton(addSetBtn, { ghost = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 14 }, text = L["Add"], font = "DFFontHighlight" }) + addSetBtn:SetScript("OnClick", DoAddSet) + + -- Count / active-set meter, right of the strip (each enabled set is a live + -- secure header — surfacing the active count makes the perf cost visible). + setMeta = tabContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + setMeta:SetPoint("RIGHT", tabContainer, "RIGHT", -2, 0) + setMeta:SetTextColor(0.45, 0.45, 0.45) + + RefreshTabs() + + -- ===== SUB-TABS (Setup / Appearance / Members) ===== + -- Splits the set editor so each concern has its own tab and no single page + -- is a long scroll. Switching just toggles group visibility via hideOn + a + -- RefreshStates() reflow (no page rebuild). The Members tab only exists for + -- player sets (boss sets auto-track boss1-8 and have no roster); the page + -- rebuilds on a frame-type change, so this list is rebuilt with it. + if activeSubTab == "members" and IsCurrentBossMode() then activeSubTab = "setup" end + local subTabDefs = { { key = "setup", label = L["Setup"] }, { key = "appearance", label = L["Appearance"] } } + if not IsCurrentBossMode() then + table.insert(subTabDefs, { key = "members", label = L["Members"] }) + end + local subTabButtons = {} + local subTabContainer = CreateFrame("Frame", nil, self.child) + subTabContainer:SetSize(460, 24) + RefreshSubTabs = function() + -- A disabled set snaps selection to Setup (its only live content is the + -- Enable toggle; the Setup controls + Frame Type grey in place). + local disabled = PinnedSetDisabled() + if disabled then activeSubTab = "setup" end + local x = 0 + for _, b in ipairs(subTabButtons) do + b:Show() + b:ClearAllPoints() + b:SetPoint("LEFT", subTabContainer, "LEFT", x, 0) + x = x + b:GetWidth() + 6 + b:SetActive(b.key == activeSubTab) -- filled toggle (white label both states) + -- While the set is off, grey + deactivate the non-Setup tabs: dim the + -- whole button (SetAlpha) and disable its mouse (EnableMouse false) so + -- there's NO hover wash and no clicks; Setup stays live. We use + -- SetAlpha+EnableMouse rather than StyleButton:SetDisabled, which fought + -- the hover wash and rendered a solid bright fill on hover. + local greyTab = disabled and b.key ~= "setup" + b:EnableMouse(not greyTab) + b:SetAlpha(greyTab and 0.4 or 1) + end + end + local subX = 0 + for i, def in ipairs(subTabDefs) do + local b = CreateFrame("Button", nil, subTabContainer, "BackdropTemplate") + b.key = def.key + b:SetHeight(22) + -- Filled toggle on the shared styler; RefreshSubTabs drives SetActive. + GUI:StyleButton(b, { text = def.label }) + -- Content-sized + flowed so this secondary row stays compact (clearly + -- subordinate to the underline tabs above), like AD's chips. + b:SetWidth(math.ceil(b.Text:GetStringWidth()) + 24) + b:SetPoint("LEFT", subTabContainer, "LEFT", subX, 0) + subX = subX + b:GetWidth() + 6 + b:SetScript("OnClick", function(self) + if self.dfDisabled then return end -- greyed tab (set disabled) — ignore clicks + activeSubTab = def.key + pagePinnedFrames.persistedSubTab = def.key + RefreshSubTabs() + pagePinnedFrames:RefreshStates() -- reflow: hideOn predicates re-evaluate against activeSubTab + end) + subTabButtons[i] = b + end + RefreshSubTabs() + AddSpace(6, "both") -- breathing room between the tab strip and the sub-row + Add(subTabContainer, 26, "both") + + AddSpace(GUI.Space.section, "both") + + -- Helper to get the pinned override key for the current active tab + local function GetPinnedKey(dbKey) + return "pinned." .. activeHighlightTab .. "." .. dbKey + end + + -- Add override indicators (star, reset, global text) to a pinned frame control + local function AddPinnedOverrideIndicators(container, lbl, dbKey, onReset) + local AutoProfilesUI = DF.AutoProfilesUI + if not AutoProfilesUI then return end + + -- Reset button (red, icon-only) + override marker (dot) — shared helpers. + local resetBtn = GUI:CreateOverrideResetButton(container, { + tooltip = L["Reset to Global"], + tooltipDesc = L["Reset this setting to its global value."], + onClick = function() if onReset then onReset() end end, + }) + resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 0) + container.overrideResetBtn = resetBtn + + local starFrame = GUI:CreateOverrideMarker(container) + starFrame:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) + container.overrideStar = starFrame + + -- Global value text + local globalText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + globalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + globalText:SetTextColor(0.4, 0.4, 0.4) + globalText:Hide() + container.overrideGlobalText = globalText + + -- Checkmark icon + local checkIcon = container:CreateTexture(nil, "OVERLAY") + checkIcon:SetSize(8, 8) + checkIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\check") + checkIcon:SetVertexColor(0.3, 0.7, 0.3) + checkIcon:Hide() + container.overrideCheckIcon = checkIcon + + container.UpdateOverrideIndicators = function(self) + -- Only the per-set `enabled` flag is layout-overridable now; every + -- other pinned setting is global/independent of auto layouts, so it + -- shows no override UI at all (no star, reset, or "Global:" text). + if not (DF.AutoProfilesUI and DF.AutoProfilesUI.IsPinnedSettingOverridable + and DF.AutoProfilesUI:IsPinnedSettingOverridable(dbKey)) then + self.overrideStar:Hide(); self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide(); self.overrideCheckIcon:Hide() + return + end + -- Debug mode + if GUI.IsOverrideDebugMode and GUI.IsOverrideDebugMode() then + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideGlobalText:SetText("(debug)") + self.overrideGlobalText:SetTextColor(1, 0.8, 0.2) + self.overrideGlobalText:Show() + self.overrideCheckIcon:Hide() + return + end + + -- Only show in raid mode while editing + if not GUI or GUI.SelectedMode ~= "raid" then + self.overrideStar:Hide(); self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide(); self.overrideCheckIcon:Hide() + return + end + + local isEditing = AutoProfilesUI and AutoProfilesUI:IsEditing() + local pinnedKey = GetPinnedKey(dbKey) + local isRuntimeOverridden = AutoProfilesUI and AutoProfilesUI:IsOverriddenByRuntime(pinnedKey) + + -- Hide everything if not editing AND not runtime-overridden + if not isEditing and not isRuntimeOverridden then + self.overrideStar:Hide(); self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide(); self.overrideCheckIcon:Hide() + return + end + + -- Runtime override mode: show star + global value, no reset button + if isRuntimeOverridden and not isEditing then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting is being overridden by the active auto layout profile. To change it, edit the profile in the Auto Layouts tab."] + self.overrideStar:Show() + self.overrideResetBtn:Hide() + self.overrideCheckIcon:Hide() + + local globalValue = AutoProfilesUI:GetRuntimeGlobalValue(pinnedKey) + local globalDisplay + if type(globalValue) == "boolean" then + globalDisplay = globalValue and L["Yes"] or L["No"] + elseif type(globalValue) == "number" then + if globalValue == math.floor(globalValue) then + globalDisplay = tostring(globalValue) + else + globalDisplay = string.format("%.2f", globalValue) + end + elseif type(globalValue) == "table" then + globalDisplay = "..." + else + globalDisplay = tostring(globalValue or "None") + end + + self.overrideGlobalText:SetText(L["Global: "] .. globalDisplay) + self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) + self.overrideGlobalText:Show() + return + end + + -- Editing mode: existing behavior + local isOverridden = AutoProfilesUI:IsSettingOverridden(pinnedKey) + local globalValue = AutoProfilesUI:GetGlobalValue(pinnedKey) + + if isOverridden then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] + self.overrideStar:Show() + self.overrideResetBtn:Show() + else + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + end + + -- Format global value for display + local globalDisplay + if type(globalValue) == "boolean" then + globalDisplay = globalValue and L["Yes"] or L["No"] + elseif type(globalValue) == "number" then + if globalValue == math.floor(globalValue) then + globalDisplay = tostring(globalValue) + else + globalDisplay = string.format("%.2f", globalValue) + end + elseif type(globalValue) == "table" then + globalDisplay = "..." + else + globalDisplay = tostring(globalValue or "None") + end + + -- Show global text with check/star positioning + if isOverridden then + self.overrideGlobalText:SetText(L["Global: "] .. globalDisplay) + self.overrideGlobalText:SetTextColor(0.4, 0.4, 0.4) + self.overrideGlobalText:Show() + self.overrideCheckIcon:Hide() + else + self.overrideGlobalText:SetText(L["Global: "] .. globalDisplay) + self.overrideGlobalText:SetTextColor(0.3, 0.7, 0.3) + self.overrideGlobalText:Show() + self.overrideCheckIcon:SetPoint("RIGHT", self.overrideGlobalText, "LEFT", -2, 0) + self.overrideCheckIcon:Show() + end + end + + -- Register for global refresh + if GUI.RegisterOverrideWidget then + GUI.RegisterOverrideWidget(container) + end + end + + -- Helper function to create refreshable checkbox + local function CreateRefreshableCheckbox(parent, label, dbKey, callback, tooltip) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(250, 24) + local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") + cb:SetPoint("LEFT", 0, 0) + GUI:StyleCheckButton(cb, { themeRoot = parent }) + local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + txt:SetText(label) + txt:SetTextColor(0.8, 0.8, 0.8) + if tooltip then + cb:SetScript("OnEnter", function(s) + GUI:ShowTooltip(s, { + title = label, + lines = { tooltip }, + }) + end) + cb:SetScript("OnLeave", function() GUI:HideTooltip() end) + end + cb:SetScript("OnClick", function(s) + local val = s:GetChecked() + -- Runtime override protection + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(GetPinnedKey(dbKey), val) then + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + return + end + GetCurrentSet()[dbKey] = val + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey(dbKey), val) + end + if callback then callback(GetCurrentSet()) end + DF:UpdateAll() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end) + container.Refresh = function() + cb:SetChecked(GetCurrentSet()[dbKey]) + -- Optional disabled state: when container.enabledWhen() is false the + -- checkbox is greyed and can't be toggled (used where one toggle is + -- only meaningful while another option is in a particular state). + if container.enabledWhen then + if container.enabledWhen() then + cb:Enable() + txt:SetTextColor(0.8, 0.8, 0.8) + cb.Check:SetVertexColor(tc.r, tc.g, tc.b) + else + cb:Disable() + txt:SetTextColor(0.4, 0.4, 0.4) + cb.Check:SetVertexColor(0.4, 0.4, 0.4) + end + end + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + + -- Override indicators with reset + AddPinnedOverrideIndicators(container, txt, dbKey, function() + local AutoProfilesUI = DF.AutoProfilesUI + if AutoProfilesUI then + AutoProfilesUI:ResetProfileSetting(GetPinnedKey(dbKey)) + cb:SetChecked(GetCurrentSet()[dbKey]) + if callback then callback(GetCurrentSet()) end + DF:UpdateAll() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + end) + + -- Group-gate hook: lets settingsGroup.disableChildrenOn grey this checkbox + -- in place (dim the box + label, block toggling) without hiding it. + container.SetEnabled = function(_, enabled) + if enabled then cb:Enable() else cb:Disable() end + txt:SetTextColor(0.8, 0.8, 0.8) + txt:SetAlpha(enabled and 1 or 0.4) + cb:SetAlpha(enabled and 1 or 0.4) + end + + container.Refresh() + table.insert(controlsToRefresh, container) + return container + end + + -- Helper function to create refreshable slider + -- + -- Delegates the slider chrome/value plumbing to the shared GUI:CreateSlider + -- builder (theme colour). The pinned value lives in GetCurrentSet()[dbKey], + -- so we drive CreateSlider via customGet/customSet (dbKey passed as nil so + -- CreateSlider's own raw-key runtime/profile/override paths stay off — the + -- pinned system keys off the PREFIXED GetPinnedKey(dbKey) instead, handled + -- here in customSet + AddPinnedOverrideIndicators below). + local function CreateRefreshableSlider(parent, label, minVal, maxVal, step, dbKey, callback) + local container + local function customGet() + return GetCurrentSet()[dbKey] or minVal + end + local function customSet(value) + -- Runtime override protection (raid auto-layout): redirect the write + -- to the active profile baseline and skip the set write entirely. + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(GetPinnedKey(dbKey), value) then + if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + return + end + GetCurrentSet()[dbKey] = value + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey(dbKey), value) + end + if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + container = GUI:CreateSlider(parent, label, minVal, maxVal, step, nil, nil, callback, nil, nil, customGet, customSet) + + -- Refresh: re-read the set's value into the slider (used on page rebuild + -- and Match-mode changes). The `updating`/suppressCallback guard inside + -- CreateSlider's UpdateValue means this programmatic SetValue does not + -- re-fire the user callback or re-write the db. + container.Refresh = function() + container.slider:SetValue(GetCurrentSet()[dbKey] or minVal) + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + container.SetValue = function(_, val) + container.slider:SetValue(val) + end + + -- Override indicators with reset (prefixed pinned key). + AddPinnedOverrideIndicators(container, container.label, dbKey, function() + local AutoProfilesUI = DF.AutoProfilesUI + if AutoProfilesUI then + AutoProfilesUI:ResetProfileSetting(GetPinnedKey(dbKey)) + container.slider:SetValue(GetCurrentSet()[dbKey] or minVal) + if callback then callback() end + DF:UpdateAll() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + end) + + container.Refresh() + table.insert(controlsToRefresh, container) + return container + end + + -- Slider whose baseline value comes from the set's Match mode (the + -- party/raid main-frame field `baselineKey`, e.g. "frameWidth"), with + -- auto-layout-style override UX: changing it stores a per-set override in + -- set[overrideKey] (e.g. "customWidth"); a gold star + reset button appear, + -- and reset clears the override to revert to the Match value. This makes + -- "Match sets the value, the user overrides it" read the same as a layout + -- override. These keys are NOT auto-layout overridable, so there is no + -- runtime/HandleRuntimeWrite path — writes go straight to the set. + local function CreateMatchOverrideSlider(parent, label, minVal, maxVal, step, overrideKey, baselineKey, callback) + -- Slider chrome/value plumbing delegated to GUI:CreateSlider (theme + -- colour). dbKey is passed as nil (these keys are NOT auto-layout + -- overridable, so CreateSlider's raw-key runtime/profile/override paths + -- must stay off) and the value is driven via customGet/customSet, which + -- read the EffectiveValue and store the per-set override below. + local container + + local function MatchValue() + local set = GetCurrentSet() + local mode = (set and set.matchMode) or GUI.SelectedMode + local mdb = DF:GetDB(mode) + -- baselineKey may be a function (mdb, set) -> value, for settings + -- whose inherited source is mode-dependent (e.g. spacing: grouped + -- raid uses frameSpacing, flat raid uses raidFlat*Spacing). + if type(baselineKey) == "function" then + return baselineKey(mdb, set) or minVal + end + return (mdb and mdb[baselineKey]) or minVal + end + -- Float-tolerant compare (half a step): fractional-step slider drags + -- produce values like 0.5999999, so exact == against the baseline + -- never matched and dragging Scale back to the inherited value left + -- a stale override + star at an identical-looking number. + local function MatchesBaseline(v) + local m = MatchValue() + return v ~= nil and m ~= nil and math.abs(v - m) < (step * 0.5) + end + local function IsOverridden() + local set = GetCurrentSet() + return set ~= nil and set[overrideKey] ~= nil and not MatchesBaseline(set[overrideKey]) + end + local function EffectiveValue() + local set = GetCurrentSet() + return (set and set[overrideKey]) or MatchValue() + end + -- Store an override only when it differs from the inherited value; setting + -- it back to the inherited value clears it (so no stale star remains). + local function SetOverride(v) + if MatchesBaseline(v) then GetCurrentSet()[overrideKey] = nil + else GetCurrentSet()[overrideKey] = v end + end + local function FmtVal(v) return step < 1 and string.format("%.1f", v) or string.format("%d", v) end + + -- Forward-declared so customSet (below) can refresh the star/reset after + -- each write; assigned once the indicator frames exist. + local UpdateIndicators + + -- customGet/customSet drive the shared slider: the displayed value is the + -- EffectiveValue (override if set, else the Match baseline) and a user + -- edit stores/clears the per-set override. dbKey is nil so CreateSlider's + -- raw-key runtime/profile/override machinery stays off. + local function customGet() return EffectiveValue() end + local function customSet(v) + SetOverride(v) + if UpdateIndicators then UpdateIndicators() end + end + container = GUI:CreateSlider(parent, label, minVal, maxVal, step, nil, nil, callback, nil, nil, customGet, customSet) + + -- Reset-to-Match button (TOPRIGHT) + gold override star to its left, + -- mirroring AddPinnedOverrideIndicators so it reads like a layout override. + -- Reset-to-Match (red, icon-only) + override marker (dot) — shared + -- helpers. The marker tooltip is dynamic (shows the inherited value) + -- so it's set below; the reset OnClick is wired further down. + local resetBtn = GUI:CreateOverrideResetButton(container, { tooltip = L["Reset to inherited value"] }) + resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 0) + + local starFrame = GUI:CreateOverrideMarker(container) + starFrame:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) + starFrame:SetScript("OnEnter", function(s) + GUI:ShowTooltip(s, { + title = L["Override active"], + lines = { string.format(L["Inherited value: %s"], FmtVal(MatchValue())) }, + }) + end) + starFrame:SetScript("OnLeave", function() GUI:HideTooltip() end) + + UpdateIndicators = function() + if IsOverridden() then starFrame:Show(); resetBtn:Show() else starFrame:Hide(); resetBtn:Hide() end + end + -- Also exposed so CreateSlider's own handlers (drag-end etc.) refresh it. + container.UpdateOverrideIndicators = UpdateIndicators + + -- Reset clears the per-set override and snaps the slider back to the + -- inherited Match value. The slider's programmatic SetValue is guarded by + -- CreateSlider's suppressCallback, so this does not re-write an override. + resetBtn:SetScript("OnClick", function() + GetCurrentSet()[overrideKey] = nil + container.slider:SetValue(EffectiveValue()) + if callback then callback() end + UpdateIndicators() + end) + + container.Refresh = function() + container.slider:SetValue(EffectiveValue()) + UpdateIndicators() + end + container.Refresh() + table.insert(controlsToRefresh, container) + return container + end + + -- Helper function to create refreshable dropdown + -- + -- Delegates the dropdown chrome/menu plumbing to the shared GUI:CreateDropdown + -- builder (theme colour). The pinned value lives in GetCurrentSet()[dbKey], + -- so we drive CreateDropdown via customGet/customSet (dbKey passed as nil so + -- the builder's own raw-key runtime/profile/override paths stay off — the + -- pinned system keys off the PREFIXED GetPinnedKey(dbKey) instead, handled + -- here in customSet + AddPinnedOverrideIndicators below). Mirrors + -- CreateRefreshableSlider's structure exactly. + local function CreateRefreshableDropdown(parent, label, options, dbKey, callback) + local container + local function customGet() + return GetCurrentSet()[dbKey] + end + local function customSet(value) + -- Runtime override protection (raid auto-layout): redirect the write + -- to the active profile baseline and skip the set write entirely. + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(GetPinnedKey(dbKey), value) then + if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + return + end + GetCurrentSet()[dbKey] = value + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey(dbKey), value) + end + if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + container = GUI:CreateDropdown(parent, label, options, nil, nil, callback, customGet, customSet) + + -- Refresh: re-read the set's value into the dropdown text (used on page + -- rebuild and Match-mode changes). UpdateText reads via customGet, so it + -- never re-writes the db or re-fires the callback. + container.Refresh = function() + container:UpdateText() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + + -- Override indicators with reset (prefixed pinned key). + AddPinnedOverrideIndicators(container, container.label, dbKey, function() + local AutoProfilesUI = DF.AutoProfilesUI + if AutoProfilesUI then + AutoProfilesUI:ResetProfileSetting(GetPinnedKey(dbKey)) + container:UpdateText() + if callback then callback() end + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end + end + end) + + container.Refresh() + table.insert(controlsToRefresh, container) + return container + end + + -- Per-set Aura/Text Designer preset picker. Unlike CreateRefreshableDropdown + -- its menu is rebuilt every open (the preset library grows/shrinks as presets + -- are created/renamed/deleted on the AD/TD pages), and the first entry, + -- "Inherit", maps to nil — the set then follows its mode's preset via the + -- resolver's FrameMode fallback. Preset refs are global-per-mode (never an + -- auto-layout override), so this writes straight to the set with no star. + -- `kind` is "aura" or "text"; `dbKey` the matching set ref. + local function CreatePinnedPresetDropdown(parent, label, kind, dbKey, callback) + local container + -- Sentinel option KEY for the "Inherit" row. Stored value nil means + -- inherit, but option tables cannot be keyed by nil, so the dropdown + -- carries this string key and customGet/customSet translate nil<->INHERIT. + local INHERIT = "__inherit__" + + local function InheritLabel() + local modeName = (DF.GetModeDesignerPresetName and DF:GetModeDesignerPresetName(kind, GUI.SelectedMode)) + or DF.DEFAULT_PRESET + return L["Inherit"] .. " (" .. tostring(modeName) .. ")" + end + + -- Dynamic option list, rebuilt on every open (the preset library grows/ + -- shrinks as presets are created/renamed/deleted on the AD/TD pages). + -- _order keeps Inherit first, then the preset names in ListDesignerPresets + -- order (DEFAULT_PRESET first, the rest sorted) — matching the old menu. + local function BuildOptions() + local opts = { [INHERIT] = InheritLabel() } + local order = { INHERIT } + for _, name in ipairs(DF:ListDesignerPresets(kind)) do + opts[name] = name + order[#order + 1] = name + end + opts._order = order + return opts + end + + -- Value get/set: nil (no per-set ref) reads as INHERIT; selecting INHERIT + -- clears the set ref back to nil so the set follows its mode's preset. + -- Preset refs are global-per-mode (never an auto-layout override), so this + -- writes straight to the set with no star (no AddPinnedOverrideIndicators). + local function customGet() + local set = GetCurrentSet() + local cur = set and set[dbKey] + return cur or INHERIT + end + local function customSet(value) + local set = GetCurrentSet() + if set then set[dbKey] = (value ~= INHERIT) and value or nil end + end + + container = GUI:CreateDropdown(parent, label, BuildOptions(), nil, nil, callback, + customGet, customSet, { optionsFunc = BuildOptions }) + + -- Refresh re-reads the set's ref into the button text (the Inherit label + -- also re-resolves for the current mode). UpdateText reads via customGet, + -- so it never re-writes the set or re-fires the callback. + container.Refresh = function() + container:UpdateText() + end + + container.Refresh() + table.insert(controlsToRefresh, container) + return container + end + + -- Helper function to update layout + local function UpdateHighlightLayout() + if DF.PinnedFrames then + DF.PinnedFrames:ApplyLayoutSettings(activeHighlightTab) + DF.PinnedFrames:ResizeContainer(activeHighlightTab) + -- If a preview container is active for the edited mode, keep it in sync + DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) + end + end + + -- Forward declaration for roster widget and unit selection header + local rosterWidget + local unitSelHeader + + -- Helper: sync players array to override system after auto-populate + local function SyncPlayersOverride() + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + local players = GetCurrentSet().players + local copy = {} + for i, v in ipairs(players) do copy[i] = v end + DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey("players"), copy) + if unitSelHeader and unitSelHeader.UpdateOverrideIndicators then + unitSelHeader:UpdateOverrideIndicators() + end + end + end + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + + -- While editing a raid auto layout, make the decouple explicit via an info + -- banner: only the per-set Enable flag can differ per layout; everything + -- else is shared. Hidden unless editing a raid layout. + local pinnedLayoutNote = GUI:CreateInfoBanner(self.child, { + tone = "info", + text = L["Auto layouts can only change whether pinned frames are shown (Enable). All other pinned frame settings are shared across layouts."], + }) + pinnedLayoutNote.hideOn = function() + return not (GUI.SelectedMode == "raid" and DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing()) + end + settingsGroup:AddWidget(pinnedLayoutNote, pinnedLayoutNote.layoutHeight or 44) + + -- SetEnabled / SetShowLabel internally use GetSetDB → IsInRaid(), + -- so calling them while editing the inactive mode would mutate the active + -- mode's state. Only call them when the selected mode matches the live mode; + -- otherwise the DB write from the checkbox itself is enough and the preview + -- reflects the change. + local function IsEditingActiveMode() + local actualMode = IsInRaid() and "raid" or "party" + return GUI.SelectedMode == actualMode + end + + -- Refresh Test Mode frames if active — enable/lock toggles affect + -- mover visibility and whether test frames should render at all. + local function RefreshTestModeIfActive() + if DF.PinnedFrames.IsTestModeActive and DF.PinnedFrames:IsTestModeActive() then + DF.PinnedFrames:ExitTestMode() + DF.PinnedFrames:EnterTestMode() + end + end + + local pinnedEnableCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Enable"], "enabled", function() + -- Enabling/disabling a set greys/ungreys the rest of its tabs + controls. + RefreshSubTabs() + self:RefreshStates() + if not DF.PinnedFrames then return end + if IsEditingActiveMode() then + DF.PinnedFrames:SetEnabled(activeHighlightTab, GetCurrentSet().enabled) + end + DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) + RefreshTestModeIfActive() + RefreshTabs() -- update the on/off pip on this set's tab + end), 28) + -- The Enable toggle itself must stay live while its set is disabled (it's the + -- only way back on); disableChildrenOn below greys every OTHER Setup control. + pinnedEnableCheck.keepEnabled = true + -- Pinned frames now lock/unlock together with the main frames (global + -- lock), so there is no per-set Lock Position toggle. Show Label is always + -- editable; the "Drag to Move" handle only appears while globally unlocked. + settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Show Label"], "showLabel", function() + if not DF.PinnedFrames then return end + if IsEditingActiveMode() then + DF.PinnedFrames:SetShowLabel(activeHighlightTab, GetCurrentSet().showLabel) + end + DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) + RefreshTestModeIfActive() + end), 28) + + -- Party-only: show this pinned set while solo (off by default — pinned + -- frames highlight other group members). Raid implies a group, so hide it + -- in raid mode. + local soloCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Show in Solo Mode"], "showInSoloMode", function() + if not DF.PinnedFrames then return end + -- Re-apply visibility so the solo gate takes effect immediately. + DF.PinnedFrames:SetEnabled(activeHighlightTab, GetCurrentSet().enabled) + RefreshTestModeIfActive() + end), 28) + soloCheck.hideOn = function() return GUI.SelectedMode == "raid" end + + -- Declutter toggles: hide auras / status icons on this set's frames for a + -- clean highlight. Re-stamp the effective DB and re-render so it applies live. + local function RefreshPinnedDisplay() + UpdateHighlightLayout() + if DF.UpdateAll then DF:UpdateAll() end + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + RefreshTestModeIfActive() + end + settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Hide Auras"], "hideAuras", RefreshPinnedDisplay), 28) + settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Hide Status Icons"], "hideIcons", RefreshPinnedDisplay), 28) + + -- Hide from Main Frames (#78): when on, this set's members are filtered out + -- of the main party/raid frames so they only appear in the pinned set. Re- + -- filter the main headers on toggle (out of combat). Boss sets pin boss units, + -- not main-frame members, so it's moot there → hidden in boss mode. + local hideMainCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Hide from Main Frames"], "hideFromMainFrames", function() + RefreshPinnedDisplay() + -- Defer past CreateRefreshableCheckbox's trailing DF:UpdateAll() so the + -- re-filter isn't stomped, then re-apply the main-frame sort directly. + C_Timer.After(0, function() + if DF.RefreshMainFrameSorting then DF:RefreshMainFrameSorting() end + end) + end, L["Hide from Main Frames Tooltip"]), 28) + hideMainCheck.hideOn = function() return IsCurrentBossMode() end + + -- Disable in PvP (GLOBAL across both modes, not per-set): keep pinned frames + -- dormant in all instanced PvP. Default on — pinned is a party/raid feature + -- and the arena/BG event storm can exhaust the per-frame budget. Turning it + -- off re-enables pinned there; the debounced RequestProcessAllSets keeps that + -- opt-in from stampeding. + -- + -- The runtime gate reads the CURRENT mode's pinnedFrames.disableInPvP (arena + -- resolves to party config, battlegrounds to raid). To make one checkbox act + -- globally — and to match the "Disable in PvP" label — the toggle writes BOTH + -- modes in lockstep, so whichever mode the gate resolves to sees the same + -- value. Hidden while editing a raid auto-layout (it's global, nothing + -- layout-specific to override); outside the editor DF.db.party/.raid are the + -- plain mode profiles, so the paired write lands on the real globals. + local function GetDisableInPvP() + local v = db.pinnedFrames.disableInPvP + if v == nil then return true end -- runtime gate treats nil as true + return v + end + local function SetDisableInPvP(val) + for _, m in ipairs({ "party", "raid" }) do + local mdb = DF.db and DF.db[m] + if mdb and mdb.pinnedFrames then + mdb.pinnedFrames.disableInPvP = val + end + end + end + local disablePvPContainer = CreateFrame("Frame", nil, self.child) + disablePvPContainer:SetSize(250, 24) + local dpvpCB = CreateFrame("CheckButton", nil, disablePvPContainer, "BackdropTemplate") + dpvpCB:SetPoint("LEFT", 0, 0) + GUI:StyleCheckButton(dpvpCB, { themeRoot = self.child }) + local dpvpTxt = disablePvPContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + dpvpTxt:SetPoint("LEFT", dpvpCB, "RIGHT", 8, 0) + dpvpTxt:SetTextColor(0.8, 0.8, 0.8) + dpvpCB:SetScript("OnClick", function(s) + SetDisableInPvP(s:GetChecked() and true or false) + -- Re-evaluate visibility for the live mode (debounced + combat-safe). + if DF.PinnedFrames and IsEditingActiveMode() and DF.PinnedFrames.RequestProcessAllSets then + DF.PinnedFrames:RequestProcessAllSets() + end + end) + dpvpCB:SetScript("OnEnter", function(s) + GUI:ShowTooltip(s, { + title = L["Disable in PvP"], + lines = { + L["Pinned frames are a party/raid feature. Leave on to keep them hidden in arena and battlegrounds, where the constant unit churn can hurt performance. Applies to both party and raid pinned sets."], + }, + }) + end) + dpvpCB:SetScript("OnLeave", function() GUI:HideTooltip() end) + disablePvPContainer.Refresh = function() + dpvpTxt:SetText(L["Disable in PvP"]) + dpvpCB:SetChecked(GetDisableInPvP()) + end + -- SetEnabled shim so settingsGroup.disableChildrenOn can grey this custom + -- container in place (dim the checkbox + label) when the set is disabled. + disablePvPContainer.SetEnabled = function(_, enabled) + dpvpCB:SetEnabled(enabled) + dpvpTxt:SetTextColor(0.8, 0.8, 0.8) + dpvpTxt:SetAlpha(enabled and 1 or 0.4) + dpvpCB:SetAlpha(enabled and 1 or 0.4) + end + -- Mode-global setting: not layout-overridable, so hide it while editing a + -- raid auto-layout (its banner already points users at the base settings). + disablePvPContainer.hideOn = function() + return DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() + end + disablePvPContainer.Refresh() + table.insert(controlsToRefresh, disablePvPContainer) + settingsGroup:AddWidget(disablePvPContainer, 28) + + -- Reset Position button + local resetPosBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") + GUI:StyleButton(resetPosBtn, { width = 130, height = 22, text = L["Reset Position"] }) + -- Route the group gate's SetEnabled through StyleButton's grey path so the + -- button dims in place (instead of native-disabling) while the set is off. + resetPosBtn.SetEnabled = function(self, enabled) self:SetDisabled(not enabled) end + resetPosBtn:SetScript("OnClick", function(self) + if self.dfDisabled then return end -- greyed (set disabled) — ignore clicks + local set = GetCurrentSet() + if not set or not DF.PinnedFrames then return end + + -- Reset position in the edited (selected) mode's DB + set.position = { point = "CENTER", x = 0, y = 0 } + + -- Apply to the real container only if editing the actual mode + local actualMode = IsInRaid() and "raid" or "party" + if GUI.SelectedMode == actualMode then + local container = DF.PinnedFrames.containers[activeHighlightTab] + if container then + container:ClearAllPoints() + container:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + DF.PinnedFrames:ApplyLayoutSettings(activeHighlightTab) + end + end + + -- Keep the preview in sync if one is active for the edited mode + DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) + end) + settingsGroup:AddWidget(resetPosBtn, 28) + + -- Label name input + local nameInputContainer = CreateFrame("Frame", nil, self.child) + nameInputContainer:SetSize(250, 44) + local nameLabel = nameInputContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameLabel:SetPoint("TOPLEFT", 0, 0) + nameLabel:SetText(L["Label Name"]) + nameLabel:SetTextColor(0.8, 0.8, 0.8) + local nameInput = CreateFrame("EditBox", nil, nameInputContainer, "BackdropTemplate") + nameInput:SetPoint("TOPLEFT", 0, -15) + nameInput:SetSize(220, 24) + GUI:StyleEditBox(nameInput) + nameInput:SetAutoFocus(false) + nameInput:SetMaxLetters(30) + nameInput:SetScript("OnEscapePressed", function(s) s:ClearFocus() end) + nameInput:SetScript("OnEnterPressed", function(s) s:ClearFocus() end) + nameInput:SetScript("OnEditFocusLost", function(s) + GetCurrentSet().name = s:GetText() + RefreshTabs() + if DF.PinnedFrames then + DF.PinnedFrames:UpdateLabel(activeHighlightTab) + -- Refresh preview label text too if a preview is active + DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) + end + end) + nameInputContainer.Refresh = function() nameInput:SetText(GetCurrentSet().name or "") end + -- SetEnabled shim: grey the label + editbox in place when the set is disabled. + nameInputContainer.SetEnabled = function(_, enabled) + nameInput:EnableMouse(enabled) + nameInput:EnableKeyboard(enabled) + if not enabled then nameInput:ClearFocus() end + nameInput:SetAlpha(enabled and 1 or 0.4) + nameLabel:SetAlpha(enabled and 1 or 0.4) + end + table.insert(controlsToRefresh, nameInputContainer) + settingsGroup:AddWidget(nameInputContainer, 48) + + Add(settingsGroup, nil, 1) + settingsGroup.hideOn = function() return activeSubTab ~= "setup" end -- Setup tab + + -- Disabled set → grey (disabled-in-place) every OTHER Setup control while the + -- Enable toggle stays live (keepEnabled above). Each control keeps its OWN + -- hideOn (soloCheck raid-mode, hideMainCheck boss-mode, disablePvPContainer + -- editing-layout) — those compose as variant/mode hides on top of the grey. + settingsGroup.disableChildrenOn = function() return PinnedSetDisabled() end + + -- ===== FRAME TYPE GROUP (Column 2) ===== + local frameTypeGroup = GUI:CreateSettingsGroup(self.child, 280) + local frameTypeHeader = GUI:CreateHeader(self.child, L["Frame Type"]) + -- Gold "New" badge next to the header (the Friendly Boss NPCs option was + -- introduced in 4.3.2). Clears when the user navigates away from the + -- Pinned Frames tab and stays cleared across sessions. + GUI:AddSectionNewBadge(frameTypeHeader, "general_pinnedframes", "frameType") + frameTypeGroup:AddWidget(frameTypeHeader, 40) + + local frameTypeOptions = { + player = L["Player Frames"], + friendlyBoss = L["Friendly Boss NPCs"], + } + + local function OnFrameTypeChanged() + if not DF.PinnedFrames then return end + -- No combat early-return: the dropdown already wrote set.frameType, + -- so bailing here left the page AND runtime desynced (Members tab + -- shown for a now-boss set, wrong Test Count max) until some later + -- rebuild. Reinitialize self-defers in combat (pendingReinitialize + -- → PLAYER_REGEN_ENABLED), and the page rebuild isn't secure work. + DF.PinnedFrames:Reinitialize() + if GUI.RefreshCurrentPage then GUI.RefreshCurrentPage() end + end + + frameTypeGroup:AddWidget( + CreateRefreshableDropdown(self.child, L["Frame Type"], frameTypeOptions, "frameType", OnFrameTypeChanged), + 55 + ) + + -- Test Count slider: how many test frames show when Test Mode is + -- active. Boss mode: 1–8 (hard WoW limit). Party player sets: 1–5 + -- (a party can't exceed 5). Raid player sets: 1–10 (covers typical + -- pinned set sizes; range kept modest for layout verification). + local function OnTestCountChanged() + if not DF.PinnedFrames then return end + if DF.PinnedFrames.IsTestModeActive and DF.PinnedFrames:IsTestModeActive() then + DF.PinnedFrames:ExitTestMode() + DF.PinnedFrames:EnterTestMode() + end + end + local testMax = IsCurrentBossMode() and 8 or (GUI.SelectedMode == "raid" and 10 or 5) + frameTypeGroup:AddWidget( + CreateRefreshableSlider(self.child, L["Test Count"], 1, testMax, 1, "testCount", OnTestCountChanged), + 55 + ) + + Add(frameTypeGroup, nil, 2) + frameTypeGroup.hideOn = function() return activeSubTab ~= "setup" end -- Setup tab + frameTypeGroup.disableChildrenOn = function() return PinnedSetDisabled() end -- greyed while the set is disabled + AddSpace(10, "both") + + -- ===== FRAME STYLE GROUP (Column 1) — inherited from your frames, overridable ===== + local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) + layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Style"]), 40) + + -- Up-front explainer for the Match inheritance + per-setting override model. + local matchInfoBanner = GUI:CreateInfoBanner(self.child, { + tone = "info", + text = L["Pinned frames are based on your Party or Raid frames — choose which below. Change any setting to override it for these frames; use the reset button beside an overridden setting to revert it to the inherited value."], + }) + layoutGroup:AddWidget(matchInfoBanner, matchInfoBanner.layoutHeight or 44) + + -- Match (Stage 2a): which mode's main frames this pinned set inherits its + -- baseline look from. Defaults to the page's OWN mode (a party set mirrors + -- party frames, a raid set mirrors raid frames); pick the opposite mode to + -- cross-match it (e.g. raid pinned frames sized/styled like party frames). + -- Per-set custom overrides (size, etc.) still win over the baseline. It + -- leads the group because it is the baseline every other option/override + -- builds on. Seed unset/legacy values to the own mode so it always shows one. + do + local pf = DF:GetDB(GUI.SelectedMode) + pf = pf and pf.pinnedFrames + if pf and pf.sets then + for _, s in pairs(pf.sets) do + if s.matchMode ~= "party" and s.matchMode ~= "raid" then + s.matchMode = GUI.SelectedMode + end + end + end + end + -- Forward refs so the Match dropdown can refresh every Match-override control's + -- displayed baseline when the matched mode changes (an un-overridden control + -- then shows the new mode's value; an overridden one keeps its star). + local pinnedWidthSlider, pinnedHeightSlider, pinnedScaleSlider + local pinnedHSpacingSlider, pinnedVSpacingSlider + local function RefreshMatchOverrides() + if pinnedWidthSlider then pinnedWidthSlider.Refresh() end + if pinnedHeightSlider then pinnedHeightSlider.Refresh() end + if pinnedScaleSlider then pinnedScaleSlider.Refresh() end + if pinnedHSpacingSlider then pinnedHSpacingSlider.Refresh() end + if pinnedVSpacingSlider then pinnedVSpacingSlider.Refresh() end + end + + local matchOptions = { party = L["Party"], raid = L["Raid"] } + layoutGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Based on"], matchOptions, "matchMode", function() + UpdateHighlightLayout() + RefreshMatchOverrides() + end), 55) + + -- Width / Height inherit the Match mode's frame size; changing either + -- stores a per-set override (gold star + reset-to-Match), exactly like a + -- layout override but with the Match value as the baseline. + pinnedWidthSlider = CreateMatchOverrideSlider(self.child, L["Width"], 20, 300, 1, "customWidth", "frameWidth", UpdateHighlightLayout) + layoutGroup:AddWidget(pinnedWidthSlider, 55) + pinnedHeightSlider = CreateMatchOverrideSlider(self.child, L["Height"], 10, 200, 1, "customHeight", "frameHeight", UpdateHighlightLayout) + layoutGroup:AddWidget(pinnedHeightSlider, 55) + + -- Scale inherits the Match mode's frameScale; overridable with star/reset. + pinnedScaleSlider = CreateMatchOverrideSlider(self.child, L["Scale"], 0.5, 2.0, 0.1, "scale", "frameScale", UpdateHighlightLayout) + layoutGroup:AddWidget(pinnedScaleSlider, 55) + + -- Per-set Aura / Text Designer preset. "Inherit" (default) follows this mode's + -- preset; pick a named preset to give this set its own aura/text look. Presets + -- are created and edited on the Aura/Text Designer pages — here you only choose + -- which one this pinned set renders with. The Text picker shows whenever the + -- Text Designer module is loaded. + layoutGroup:AddWidget(CreatePinnedPresetDropdown(self.child, L["Aura Designer Template"], "aura", "auraDesignerPreset", RefreshPinnedDisplay), 55) + if DF.TextDesigner then + layoutGroup:AddWidget(CreatePinnedPresetDropdown(self.child, L["Text Designer Template"], "text", "textDesignerPreset", RefreshPinnedDisplay), 55) + end + + -- Border Override (Stage 2b): a single toggle. Off → inherit the Based-on + -- mode's frame border. On → snapshot that border into the set and reveal the + -- full border controls (independent for this set). The proxy delegates + -- reads/writes to the current set so CreateBorderControls tracks tab/mode. + local borderSetProxy = setmetatable({}, { + __index = function(_, k) local s = GetCurrentSet(); return s and s[k] end, + __newindex = function(_, k, v) local s = GetCurrentSet(); if s then s[k] = v end end, + }) + -- Declared first so the border controls' update hooks can refresh the + -- reset icon's visibility after an edit. + local borderResetIcon + local function refreshBorderReset() + if borderResetIcon then + borderResetIcon:SetShown(DF.PinnedFrames and DF.PinnedFrames:IsBorderOverrideChanged(GetCurrentSet()) or false) + end + end + + local borderCheck = CreateRefreshableCheckbox(self.child, L["Override Border"], "borderOverride", function() + if GetCurrentSet().borderOverride and DF.PinnedFrames then + DF.PinnedFrames:SeedSetBorderOverride(GetCurrentSet()) + end + UpdateHighlightLayout() + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end) + -- Reset-to-inherited icon on the row's right, matching the refresh icon the + -- other override controls use. Re-snapshots the border from the Based-on + -- frames (discards edits). Shown only when a border setting actually differs + -- from the inherited value. + do + local rb = GUI:CreateOverrideResetButton(borderCheck, { + tooltip = L["Reset Border to Inherited"], + onClick = function() + if DF.PinnedFrames then DF.PinnedFrames:SeedSetBorderOverride(GetCurrentSet(), true) end + UpdateHighlightLayout() + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }) + rb:SetPoint("TOPRIGHT", borderCheck, "TOPRIGHT", 0, -2) + borderResetIcon = rb + local origRefresh = borderCheck.Refresh + borderCheck.Refresh = function(...) if origRefresh then origRefresh(...) end refreshBorderReset() end + refreshBorderReset() + end + layoutGroup:AddWidget(borderCheck, 28) + GUI:CreateBorderControls(layoutGroup, borderSetProxy, "frame", { + parent = self.child, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, + classColor = true, roleColor = true, + alpha = true, + }, + fullUpdate = function() UpdateHighlightLayout(); refreshBorderReset() end, + lightUpdate = function() UpdateHighlightLayout(); refreshBorderReset() end, + lightColors = function() UpdateHighlightLayout(); refreshBorderReset() end, + refreshStates = function() if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end end, + hideWhen = function() return not (GetCurrentSet() and GetCurrentSet().borderOverride) end, + sizeMin = 1, sizeMax = 16, sizeStep = 1, + }) + + Add(layoutGroup, nil, 1) + layoutGroup.hideOn = function() return activeSubTab ~= "appearance" end -- Appearance tab + + -- ===== LAYOUT GROUP (Column 2) — pinned arrangement. Direction / growth / + -- units-per-row are pinned-only (no main-frame equivalent). Spacing IS a + -- Match override: it inherits the Based-on mode's frameSpacing (grouped) / + -- raidFlat*Spacing (flat) so a pinned set stays aligned with the frames it + -- mirrors, overridable per set. ===== + local arrangeGroup = GUI:CreateSettingsGroup(self.child, 280) + arrangeGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) + + -- Direction: how the pinned frames flow (pinned-only; NOT inherited — the + -- main frames' growDirection means group Rows/Columns, a different concept). + local directionOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } + arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Direction"], directionOptions, "growDirection", UpdateHighlightLayout), 55) + + -- CENTER intentionally omitted: it isn't truly implemented for pinned + -- frames (frames grow START-style; only the anchor/label shift). START/END + -- only for now; a real centred layout can be added later. + local frameAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } + arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Frames Grow From"], frameAnchorOptions, "frameAnchor", UpdateHighlightLayout), 55) + + local columnAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } + arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Columns Grow From"], columnAnchorOptions, "columnAnchor", UpdateHighlightLayout), 55) + + arrangeGroup:AddWidget(CreateRefreshableSlider(self.child, L["Units Per Row"], 1, 10, 1, "unitsPerRow", UpdateHighlightLayout), 55) + -- Spacing inherits the Based-on mode's layout spacing (grouped -> frameSpacing, + -- flat raid -> raidFlat*Spacing); a per-set value overrides it (gold star + reset). + local function SpacingBaseline(flatKey) + return function(mdb) + if mdb and mdb.raidUseGroups == false then return mdb[flatKey] or 2 end + return (mdb and mdb.frameSpacing) or 2 + end + end + pinnedHSpacingSlider = CreateMatchOverrideSlider(self.child, L["Horizontal Spacing"], -5, 50, 1, "horizontalSpacing", SpacingBaseline("raidFlatHorizontalSpacing"), UpdateHighlightLayout) + arrangeGroup:AddWidget(pinnedHSpacingSlider, 55) + pinnedVSpacingSlider = CreateMatchOverrideSlider(self.child, L["Vertical Spacing"], -5, 50, 1, "verticalSpacing", SpacingBaseline("raidFlatVerticalSpacing"), UpdateHighlightLayout) + arrangeGroup:AddWidget(pinnedVSpacingSlider, 55) + Add(arrangeGroup, nil, 2) + arrangeGroup.hideOn = function() return activeSubTab ~= "appearance" end -- Appearance tab + + if not IsCurrentBossMode() then + -- ===== MEMBERS SUB-TAB: Unit Selection (roster) first, then Auto-Populate. + -- Both are "who's in this group", so they lead the Members view; Settings / + -- Frame Type / Frame Style / Layout live on the Appearance sub-tab. ===== + local membersHideOn = function() return activeSubTab ~= "members" end + + -- Unit Selection header with override indicator + unitSelHeader = CreateFrame("Frame", nil, self.child) + unitSelHeader:SetSize(500, 40) + unitSelHeader.hideOn = membersHideOn + local unitSelTitle = unitSelHeader:CreateFontString(nil, "OVERLAY", "DFFontNormal") + unitSelTitle:SetPoint("LEFT", 0, 0) + unitSelTitle:SetText(L["Unit Selection"]) + -- Match the GUI:CreateHeader norm: theme-colored + auto theme listener + -- (every sibling section title uses CreateHeader; replicate it here since + -- this header is composite with a count badge + override indicator). + local _utc = GUI.GetThemeColor() + unitSelTitle:SetTextColor(_utc.r, _utc.g, _utc.b) + unitSelTitle.UpdateTheme = function() + local nc = GUI.GetThemeColor() + unitSelTitle:SetTextColor(nc.r, nc.g, nc.b) + end + if not self.child.ThemeListeners then self.child.ThemeListeners = {} end + table.insert(self.child.ThemeListeners, unitSelTitle) + + -- "N pinned" count beside the title, themed. Updated on any roster change. + local unitSelCount = unitSelHeader:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + unitSelCount:SetPoint("LEFT", unitSelTitle, "RIGHT", 8, 0) + local function UpdateUnitSelCount() + local n = #((GetCurrentSet() and GetCurrentSet().players) or {}) + unitSelCount:SetText(n .. " " .. L["pinned"]) + local tc = GUI.GetThemeColor() + unitSelCount:SetTextColor(tc.r, tc.g, tc.b) + end + UpdateUnitSelCount() + + -- Override indicator for players list (header-level) + AddPinnedOverrideIndicators(unitSelHeader, unitSelTitle, "players", function() + local AutoProfilesUI = DF.AutoProfilesUI + if AutoProfilesUI then + AutoProfilesUI:ResetProfileSetting(GetPinnedKey("players")) + if rosterWidget and rosterWidget.Refresh then rosterWidget:Refresh() end + if DF.PinnedFrames then DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) end + if unitSelHeader.UpdateOverrideIndicators then unitSelHeader:UpdateOverrideIndicators() end + end + end) + unitSelHeader.Refresh = function(self) + if self.UpdateOverrideIndicators then self:UpdateOverrideIndicators() end + UpdateUnitSelCount() + end + + Add(unitSelHeader, 40, "both") + + rosterWidget = GUI:CreateHighlightRosterWidget( + self.child, + function() return GetCurrentSet().players end, + function(players) + local set = GetCurrentSet() + set.players = players + -- Sync manualPlayers: every player currently in the list via GUI is manual. + -- Rebuild the lookup to match exactly what's in the list now. + if not set.manualPlayers then set.manualPlayers = {} end + local newManual = {} + for _, name in ipairs(players) do + -- Preserve existing manual entries, add any new ones + newManual[name] = true + end + set.manualPlayers = newManual + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + -- Deep copy the players array for the override + local copy = {} + for i, v in ipairs(players) do copy[i] = v end + DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey("players"), copy) + if unitSelHeader.UpdateOverrideIndicators then unitSelHeader:UpdateOverrideIndicators() end + end + end, + function() + if DF.PinnedFrames then DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) end + end + ) + + local originalRefresh = rosterWidget.Refresh + rosterWidget.Refresh = function(s) + if originalRefresh then originalRefresh(s) end + if unitSelHeader.UpdateOverrideIndicators then unitSelHeader:UpdateOverrideIndicators() end + UpdateUnitSelCount() + RefreshTabs() -- keep the tab member count in sync with the roster + end + table.insert(controlsToRefresh, rosterWidget) + table.insert(controlsToRefresh, unitSelHeader) + -- The roster widget's content runs to ~364px (panes 240 + role buttons + + -- the "Add Offline Player" input), taller than its 340 frame. Reserve the + -- real height (plus a gap) so the following Auto-Populate group doesn't ride + -- up into the manual-entry row. + Add(rosterWidget, 378, "both") + rosterWidget.hideOn = membersHideOn + + -- ===== AUTO-POPULATE GROUP (full width, under the roster) ===== + local autoPopGroup = GUI:CreateSettingsGroup(self.child, 560) + autoPopGroup:AddWidget(GUI:CreateHeader(self.child, L["Auto-Populate"]), 40) + autoPopGroup:AddWidget(GUI:CreateLabel(self.child, L["Automatically add players by role when they join your group."], 510), 20) + + autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Auto-add Tanks"], "autoAddTanks", function() + if GetCurrentSet().autoAddTanks and DF.PinnedFrames then + DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) + DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) + if rosterWidget then rosterWidget:Refresh() end + SyncPlayersOverride() + end + end), 28) + autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Auto-add Healers"], "autoAddHealers", function() + if GetCurrentSet().autoAddHealers and DF.PinnedFrames then + DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) + DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) + if rosterWidget then rosterWidget:Refresh() end + SyncPlayersOverride() + end + end), 28) + autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Auto-add DPS"], "autoAddDPS", function() + if GetCurrentSet().autoAddDPS and DF.PinnedFrames then + DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) + DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) + if rosterWidget then rosterWidget:Refresh() end + SyncPlayersOverride() + end + end), 28) + -- Exclude Self: keep the player out of this set's auto-add (e.g. Aug Evoker + -- who buffs others). Re-runs auto-populate so self is added/removed live. + autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Exclude Self"], "excludeSelf", function() + if DF.PinnedFrames then + DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) + DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) + if rosterWidget then rosterWidget:Refresh() end + SyncPlayersOverride() + end + end), 28) + autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Keep when offline/left"], "keepOfflinePlayers", function() end, L["Keep when offline/left Tooltip"]), 28) + + Add(autoPopGroup, nil, "both") + autoPopGroup.hideOn = membersHideOn + end -- not IsCurrentBossMode + + RefreshControls() + + -- Show preview containers if editing a non-active mode + -- (e.g. raid settings while actually in a party): lets the user + -- position/scale the pinned frames for that mode without being in it. + if DF.PinnedFrames then + DF.PinnedFrames:ShowPreview(GUI.SelectedMode) + end + end) + + DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) +end \ No newline at end of file diff --git a/GUI/Pages/Indicators.lua b/GUI/Pages/Indicators.lua new file mode 100644 index 00000000..a09ce116 --- /dev/null +++ b/GUI/Pages/Indicators.lua @@ -0,0 +1,1918 @@ +-- Part 4 of the settings pages, split from Options.lua. +-- The parts run as a chain so the pages build in their original order. +local addonName, DF = ... +local format = string.format +function DF._SetupGUIPagesPart4(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) + BuildPage(pageBuffs, function(self, db, Add, AddSpace, AddSyncPoint) + -- ======================================== + -- AD COEXISTENCE INFO BANNER + -- Shows when Aura Designer is active (with or without buffs). + -- ======================================== + local adBanner = GUI:CreateInfoBanner(self.child, {tone = "info"}) + + -- Link markup helper: |cCOLOR|HlinkData|hText|h|r — the banner recolours + -- links via the theme, so the markup colour is only a placeholder. + local function adLink(data, text) + return "|cffffffff|H" .. data .. "|h" .. text .. "|h|r" + end + local function adOnLink(data) + if data == "enableBuffs" then + db.showBuffs = true + self:RefreshStates() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + elseif data == "openAD" then + if GUI.SelectTab then GUI.SelectTab("auras_auradesigner") end + end + end + + -- Refresh banner content based on current state + adBanner.refreshContent = function(b, d) + local _adCfg = DF.GetModeAuraDesigner and DF:GetModeAuraDesigner((d == DF.db.raid) and "raid" or "party") + local adEnabled = _adCfg and _adCfg.enabled + if adEnabled and d.showBuffs then + b:SetHTML(L["Aura Designer is active alongside Buffs."] .. " " .. + adLink("openAD", L["Open Aura Designer"]), adOnLink) + elseif adEnabled and not d.showBuffs then + b:SetHTML(L["Buffs are disabled. Aura Designer is managing your auras."] .. " " .. + adLink("enableBuffs", L["Enable Buffs"]) .. " " .. + adLink("openAD", L["Open Aura Designer"]), adOnLink) + end + end + + adBanner.hideOn = function(d) + local _adCfg = DF.GetModeAuraDesigner and DF:GetModeAuraDesigner((d == DF.db.raid) and "raid" or "party") + return not (_adCfg and _adCfg.enabled) + end + + Add(adBanner, 32, "both") + + -- ======================================== + -- AD DISCOVERY BANNER + -- The INVERSE of the coexistence banner above: shown only when the Aura + -- Designer is NOT active, to point users who want more than one look for + -- every buff at per-slot control + advanced indicators. Its hideOn is the + -- exact negation of adBanner's, so precisely one AD banner ever occupies + -- this slot (a hidden banner collapses to zero height — no gap). + -- success tone (an inviting green), but a "widget" glyph overrides the tone's + -- default check so it reads as "advanced indicators available", not a + -- completed-state confirmation. Reuses adLink/adOnLink (the openAD path). + -- ======================================== + local adPromoBanner = GUI:CreateInfoBanner(self.child, {tone = "success"}) + adPromoBanner:SetIconTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\widget_small") + adPromoBanner.refreshContent = function(b) + b:SetHTML(L["Want per-spell control? The Aura Designer lets you place any buff exactly where you want, with advanced indicators — expiry glyphs, duration bars, custom borders and sounds."] .. " " .. + adLink("openAD", L["Open Aura Designer"]), adOnLink) + end + adPromoBanner.hideOn = function(d) + local _adCfg = DF.GetModeAuraDesigner and DF:GetModeAuraDesigner((d == DF.db.raid) and "raid" or "party") + return (_adCfg and _adCfg.enabled) and true or false -- hide when AD IS active + end + Add(adPromoBanner, 32, "both") + + -- Copy button at top right + -- "directBuff" covers the Order & Limits sort keys (directBuffSortOrder / + -- SortMineFirst / SortReverse) — they do not start with "buff", so they were + -- owned by no section and skipped by Copy, Sync and Reset alike. + Add(CreateCopyButton(self.child, {"buff", "showBuffs", "directBuff"}, L["Buffs"], "auras_buffs"), 25, 2) + + -- ===== DEDUPLICATION ===== + local dedupGroup = GUI:CreateSettingsGroup(self.child, 280) + dedupGroup:AddWidget(GUI:CreateHeader(self.child, L["Deduplication"]), 40) + -- The 12.1 alert banner that used to sit here is gone: both halves of the + -- toggle are expressible again (Aura Designer via excludeSpellIDs, the + -- Defensive Bar via its own resolved spell-ID map or a negated category — + -- see BuildDirectBuffFilters / BuildAuraRowConfig), and the multi-filter + -- duplicate it warned about cannot happen on a single-group buff row. + -- What the checkbox does now fits a tooltip; a danger banner would read as + -- "something is broken here". + local dedupCb = GUI:CreateCheckbox(self.child, L["Hide Duplicate Buffs"], db, "buffDeduplicateDefensives", function() + -- Bump the aura layout version so the factory buff row rebuilds with the new + -- exclusion set (InvalidateAuraLayout -> RefreshFactoryRows -> DriveBuffFactory); + -- UpdateAllAuras re-scans for the legacy (pre-12.1) dedup path. + DF:InvalidateAuraLayout() + DF:UpdateAllAuras() + end) + dedupCb.tooltip = L["Hides buffs that are already shown elsewhere — by an Aura Designer indicator, or on the Defensive Bar — so they don't appear twice."] + dedupGroup:AddWidget(dedupCb, 30) + Add(dedupGroup, nil, 1) + + local anchorOptions = { + CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], + TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], + } + + -- Settings Group (col1) + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + local showBuffsCb = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Buffs"], db, "showBuffs", function() + self:RefreshStates() + -- Re-scan auras on visible frames (not just layout): the show/hide gate + -- lives in the UNIT_AURA-driven UpdateAuras path, so UpdateAllFrames alone + -- (layout-only) leaves already-shown auras until the next aura event. Use + -- the same refresh the Max Buffs slider uses. + DF:RefreshAllVisibleFrames() + end), 30) + -- Re-sync checked state when value changes externally (e.g. AD banner click) + showBuffsCb.refreshContent = function(self) + local onShow = self:GetScript("OnShow") + if onShow then onShow(self) end + end + local buffMax = settingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Buffs"], 0, 8, 1, db, "buffMax", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + buffMax.disableOn = function(d) return not d.showBuffs end + Add(settingsGroup, nil, 1) + + -- Appearance Group (col2). Icon Size / Scale / Alpha are how the row LOOKS, so + -- they sit in column 2 with the other styling, matching Missing Buffs and + -- Defensive Icon. They used to live in Settings above, which made this the only + -- aura family where the same three sliders were classed as geometry. + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + local buffSize = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 10, 40, 1, db, "buffSize", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffSize.disableOn = function(d) return not d.showBuffs end + local buffScale = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "buffScale", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffScale.disableOn = function(d) return not d.showBuffs end + local buffAlpha = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.0, 1.0, 0.05, db, "buffAlpha", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffAlpha.disableOn = function(d) return not d.showBuffs end + Add(appearanceGroup, nil, 2) + + -- Layout Group (col1) + local gridGroup = GUI:CreateSettingsGroup(self.child, 280) + gridGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) + local buffWrap = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Per Row"], 1, 8, 1, db, "buffWrap", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + -- Greys out (NOT a 12.1 frost) whenever the row can't have more than one icon per + -- line: the row is off, or the growth is vertical-primary, where the native flow + -- renders a single column and "icons per row" has nothing to count. That's ordinary + -- contextual state — the control works fine horizontally — so it uses the normal grey + -- seam rather than the 12.1 blocked registry, which is reserved for "the game cannot + -- do this". Flipping Orientation re-enables it live via RefreshStates. + -- (Why a vertical column is unavoidable, re-verified against the 68914 dump: + -- ValidateAuraGroupLayoutOptions accepts only elementSpacing / lineSpacing / + -- groupSpacing / groupLineSpacing / forceNewLine / elementWidth / elementHeight / + -- layoutIndex — no primary-axis field and no wrap count — and + -- SetFlowLayoutGrowthDirection(h, v) picks which way lines grow, not whether the + -- flow is column-primary.) + buffWrap.disableOn = function(d) + if not d.showBuffs then return true end + local g = d.buffGrowth or "" + -- Vertical-primary AND vertical-centred growth both render a single column. + return DF:FactoryOwnsBuffRow(d) and (g:sub(1, 2) == "UP" or g:sub(1, 4) == "DOWN" + or g == "CENTER_LEFT" or g == "CENTER_RIGHT") + end + -- CENTER growth direction: supported on factory rows since the centre-pinned + -- box in AuraContainer.lua resolveGrowthLayout (the self-sizing container + -- keeps the row centred) — the old blocked-registry entry is gone. + local buffPaddingX = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing X"], -5, 10, 1, db, "buffPaddingX", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffPaddingX.disableOn = function(d) return not d.showBuffs end + local buffPaddingY = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing Y"], -5, 10, 1, db, "buffPaddingY", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffPaddingY.disableOn = function(d) return not d.showBuffs end + Add(gridGroup, nil, 1) + + -- Position Group (col1) + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + local buffAnchor = positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "buffAnchor", nil), 55) + buffAnchor.disableOn = function(d) return not d.showBuffs end + local buffGrowth = positionGroup:AddWidget(GUI:CreateGrowthControl(self.child, db, "buffGrowth", nil), 155) + buffGrowth.disableOn = function(d) return not d.showBuffs end + local buffOffsetX = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "buffOffsetX", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffOffsetX.disableOn = function(d) return not d.showBuffs end + local buffOffsetY = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "buffOffsetY", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) + buffOffsetY.disableOn = function(d) return not d.showBuffs end + Add(positionGroup, nil, 1) + + -- Border Group (col2) + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + -- Full border toolkit via the unified helper (Stage 5.5 Phase 2). No + -- class/role colour (aura indicators aren't unit-class). Greys out when + -- buffs are off, like every other control on this page. + -- Border Animation is intentionally NOT offered on the buff/debuff rows: + -- these containers can hold many icons and animating each border is a + -- per-frame FPS cost, so DF exposes border animations only on the + -- low-count elements (Defensive / Missing Buff) and the Aura Designer. + GUI:CreateBorderControls(borderGroup, db, "buff", { + parent = self.child, + include = { inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true }, + sizeMin = 0, sizeMax = 8, sizeStep = 1, + fullUpdate = function() if DF.UpdateAllFrames then DF:UpdateAllFrames() end end, + lightUpdate = function() DF:LightweightUpdateAuraBorder("buff") end, + lightColors = function() DF:LightweightUpdateAuraBorder("buff") end, + refreshStates = function() self:RefreshStates() end, + disableWhen = function(d) return not d.showBuffs end, + }) + Add(borderGroup, nil, 2) + + -- Stack Count Group (col2) — the shared TextStyle control block (font/scale/ + -- outline/shadow/colour/anchor/offsets/justify) + the feature-specific extras. + local stackCountGroup = GUI:CreateSettingsGroup(self.child, 280) + stackCountGroup:AddWidget(GUI:CreateHeader(self.child, L["Stack Count"]), 40) + GUI:CreateTextControls(stackCountGroup, db, "buffStack", { + parent = self.child, + include = { color = true }, + onChange = function() DF:LightweightUpdateAuraStackText("buff") end, + onDrag = function() DF:LightweightUpdateAuraStackText("buff") end, + }) + -- (No "Min Stacks to Show": a stacks formatter is FORBIDDEN on container rows — it + -- throws on the secret combat stack count inside Blizzard's dirty pass and bricks + -- the container (see the Features/Auras.lua tombstone). Native display is + -- "counts > 1", so a custom minimum cannot be expressed; the setting is gone.) + -- Grey the whole group when Buffs are off, matching Settings/Position/Grid. + stackCountGroup.disableChildrenOn = function(d) return not d.showBuffs end + Add(stackCountGroup, nil, 1) + + -- Duration Text Group (col2) + local durationGroup = GUI:CreateSettingsGroup(self.child, 280) + durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration"]), 40) + durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "buffShowDuration", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 30) + -- The cooldown swipe (radial sweep) is the OTHER way time-remaining is + -- shown, so it lives here with Duration Text rather than under Border. + durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Cooldown Swipe"], db, "buffHideSwipe", nil), 30) + -- Icon-sized formats only: Number "14" / Seconds "14s" / Percent "45%". + -- FULL ("14 Seconds") overflows a 20px icon (never fit, delisted with #5's + -- percent work — a saved FULL still renders until the user re-picks); the + -- combined "12s (45%)" is AD-bar-only for the same reason. + local durationFormatOptions = { NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], + _order = { "NUMBER", "SHORT", "PERCENT" } } + local durFormat = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Duration Format"], durationFormatOptions, db, "buffDurationFormat", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames(); GUI:RefreshCurrentPage() end), 55) + durFormat.disableOn = function(d) return not d.buffShowDuration end + -- Shared TextStyle control block (font/scale/outline/shadow/colour/anchor/ + -- offsets/justify). The static colour greys out while Color-by-Time owns it. + GUI:CreateTextControls(durationGroup, db, "buffDuration", { + parent = self.child, + include = { color = true }, + colorLabel = L["Duration Color"], + disableOn = function(d) return not d.buffShowDuration end, + colorDisableOn = function(d) return d.buffDurationColorByTime end, + onChange = function() DF:LightweightUpdateAuraDurationText("buff") end, + onDrag = function() DF:LightweightUpdateAuraDurationText("buff") end, + }) + local durColor = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Time Remaining"], db, "buffDurationColorByTime", function() self:RefreshStates(); DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) + durColor.disableOn = function(d) return not d.buffShowDuration end + AddColorsPageLink(durationGroup, self.child) + -- Hide Above can't compose with the Percent format (its threshold is seconds + -- banded into a seconds-sampled formatter — see GetDurationFormatFields). + local durHideAbove = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Above Threshold"], db, "buffDurationHideAboveEnabled", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) + durHideAbove.disableOn = function(d) return not d.buffShowDuration or DF:IsPercentDurationFormat(d.buffDurationFormat) end + local durHideAboveSlider = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Above (seconds)"], 1, 60, 1, db, "buffDurationHideAboveThreshold", nil, function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 55) + durHideAboveSlider.disableOn = function(d) return not d.buffShowDuration or not d.buffDurationHideAboveEnabled or DF:IsPercentDurationFormat(d.buffDurationFormat) end + local durHidePerm = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Duration on Permanent Auras"], db, "buffDurationHideOnPermanent", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) + durHidePerm.disableOn = function(d) return not d.buffShowDuration end + -- Grey the whole group when Buffs are off (composes with the per-control + -- buffShowDuration gates), matching Settings/Position/Grid. + durationGroup.disableChildrenOn = function(d) return not d.showBuffs end + Add(durationGroup, nil, 2) + + -- (No Expiring Indicator group: the pre-12.1 expiring border/tint was driven by a + -- ~3 Hz ticker reading remaining time, which is SECRET on 12.1. Removed 2026-07-25 + -- rather than left frosted. The 12.1-safe replacement is the DF.Expiration engine + -- (Features/Expiration.lua) + GUI:CreateExpirationControls, currently adopted by the + -- Aura Designer only -- rolling it out to these rows is a separate, unscheduled job.) + + -- ===== DURATION BAR ===== (12.1 factory rows only — the native + -- container drains the strip render-side; the legacy renderer has no bar) + -- + -- The collapsible section used to carry this predicate and hide the bar + -- with itself; with the section gone the box declares it directly. + local function HideDurationBar(d) return not DF:FactoryOwnsBuffRow(d) end + + -- Every bar edit routes through the factory drive: the sig split decides + -- Rebuild (enable/position/height/gap — layout reservation) vs in-place + -- restyle (texture/colours) — same callback either way. + local function BuffBarChanged() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end + + local durBarGroup = GUI:CreateSettingsGroup(self.child, 280) + durBarGroup.hideOn = HideDurationBar + -- "Duration Bar", not "Settings": the section that scoped that name is + -- gone, and the page already has a Settings box at the top. + durBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Bar"]), 40) + durBarGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows a bar on each icon that drains with the aura's remaining time."], 250), 30) + local buffBarEnable = durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Duration Bar"], db, "buffDurationBarEnabled", function() + self:RefreshStates() + BuffBarChanged() + end), 30) + buffBarEnable.keepEnabled = true + buffBarEnable.disableOn = function(d) return not d.showBuffs end + durBarGroup.disableChildrenOn = function(d) return not d.showBuffs or not d.buffDurationBarEnabled end + -- Where the bar sits, then what it looks like. One box rather than two, + -- matching Debuffs: every other optional element on the page is a single + -- box, and splitting only this one made the bar read as more of a feature + -- than its neighbours while taking up half of column 2. + durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, db, "buffDurationBarPosition", BuffBarChanged), 55) + durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 12, 1, db, "buffDurationBarHeight", nil, BuffBarChanged, true), 55) + durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Gap"], 0, 10, 1, db, "buffDurationBarGap", nil, BuffBarChanged, true), 55) + durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], DF:GetDurationBarColorModes(), db, "buffDurationBarColorMode", function() + self:RefreshStates() + BuffBarChanged() + end), 55) + local buffBarTex = durBarGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "buffDurationBarTexture", BuffBarChanged), 55) + local buffBarCol = durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "buffDurationBarColor", true, BuffBarChanged), 30) + -- A curve mode brings its own ramp texture and forces white, so these two do + -- nothing while it is selected - dim them rather than leave dead controls live. + buffBarTex.disableOn = function(d) return DF:IsDurationBarCurveMode(d.buffDurationBarColorMode) end + buffBarCol.disableOn = buffBarTex.disableOn + durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "buffDurationBarBGColor", true, BuffBarChanged), 30) + durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "buffDurationBarReverseFill", BuffBarChanged), 30) + Add(durBarGroup, nil, 2) + + -- ======================================== + -- ORDER & LIMITS (moved here from the old Aura Filters page) + -- ======================================== + -- These decide the ORDER of what already passed the filters, and cap it by + -- duration. Neither is a filter in the sense the Filters page means -- a + -- named set of spells you switch on -- so they belong with the bar they act + -- on. Which filters are active is on Aura Filters. + local BuffOrderChanged = function() + if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + end + + local buffOrderGroup = GUI:CreateSettingsGroup(self.child, 280) + buffOrderGroup:AddWidget(GUI:CreateHeader(self.child, L["Order & Limits"]), GUI.RowHeight.sectionHeader) + + local buffSortOptions = { + DEFAULT = L["Default (Slot Order)"], + TIME = L["Time Remaining"], + NAME = L["Alphabetical"], + APPLIED = L["Order Applied"], + _order = { "DEFAULT", "TIME", "NAME", "APPLIED" }, + } + buffOrderGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], buffSortOptions, db, "directBuffSortOrder", function() + BuffOrderChanged() + self:RefreshStates() -- Mine First greys while Sort Order = Default + end), 55) + + -- Sort refinements (native rows only — the legacy Lua scan doesn't read them) + local bfSortMine = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["My Auras First"], db, "directBuffSortMineFirst", BuffOrderChanged), 30) + bfSortMine.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end + bfSortMine.disableOn = function(d) return not DF:SortOrderSupportsMineFirst(d.directBuffSortOrder) end + bfSortMine.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] + local bfSortRev = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Order"], db, "directBuffSortReverse", BuffOrderChanged), 30) + bfSortRev.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end + bfSortRev.tooltip = L["Reverse the sort direction."] + + -- Native-only: max TOTAL duration filter (12.1 candidateFilters.maxDuration). + -- Hidden while the legacy render owns the row (not expressible there). + local bfMaxDur = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Long Buffs"], db, "buffMaxDurationEnabled", function() + BuffOrderChanged() + self:RefreshStates() + end), 30) + bfMaxDur.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end + bfMaxDur.tooltip = L["Hide buffs whose total duration is longer than the threshold - e.g. hour-long food and flask buffs. Buffs with no duration (permanent auras) are also hidden while this is on."] + local bfMaxDurSlider = buffOrderGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Longer Than (minutes)"], 1, 30, 1, db, "buffMaxDurationMinutes", nil, BuffOrderChanged), 55) + bfMaxDurSlider.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end + bfMaxDurSlider.disableOn = function(d) return not d.buffMaxDurationEnabled end + + -- Independent of Hide Long Buffs — but subsumed by it (a finite cap already + -- rejects duration-0 auras), hence the tooltip honesty. + local bfHidePerm = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Permanent Auras"], db, "buffHidePermanent", BuffOrderChanged), 30) + bfHidePerm.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end + bfHidePerm.tooltip = L["Hide buffs with no duration, such as auras that last until cancelled. Hide Long Buffs also hides these while it is on."] + Add(buffOrderGroup, nil, 1) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_filterdesigner", label = L["Aura Filters"]}, + {pageId = "display_tooltips", label = L["Buff Tooltips"]}, + {pageId = "general_integrations", label = L["Integrations"]}, + {pageId = "auras_missingbuffs", label = L["Missing Buffs"]}, + }), 30, "both") + end) + + -- Auras > Debuffs (combined Layout + Appearance with collapsible sections) + local pageDebuffs = CreateSubTab("auras", "auras_debuffs", L["Debuffs"]) + BuildPage(pageDebuffs, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + -- "directDebuff" — same omission as Buffs above, plus ShowAll / DispellableMode. + Add(CreateCopyButton(self.child, {"debuff", "showDebuffs", "directDebuff"}, L["Debuffs"], "auras_debuffs"), 25, 2) + + + + local anchorOptions = { + CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], + TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], + } + + -- ===== DEDUPLICATION ===== + -- Same section, same position as the Buffs page: its own box at the top + -- of column 1, ahead of Settings. The two pages' dedupe toggles must be + -- findable in the same place. + local dedupGroup = GUI:CreateSettingsGroup(self.child, 280) + dedupGroup:AddWidget(GUI:CreateHeader(self.child, L["Deduplication"]), 40) + -- Inlined rather than reusing DebuffOrderChanged: that local is declared + -- with the Order & Limits box FURTHER DOWN this function, so naming it + -- here would read as a nil global — legal Lua, parses clean, and the + -- checkbox would just silently do nothing. + local dfDedup = GUI:CreateCheckbox(self.child, L["Hide Duplicate Debuffs"], db, "debuffDeduplicateDesigner", function() + if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + end) + dfDedup.tooltip = L["Hides debuffs that an Aura Designer group is already showing, so they don't appear twice."] + dedupGroup:AddWidget(dfDedup, 30) + Add(dedupGroup, nil, 1) + + -- Settings Group (col1) + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Debuffs"], db, "showDebuffs", function() + self:RefreshStates() + -- See Show Buffs above: re-scan auras on visible frames so a static + -- debuff hides/shows immediately instead of waiting for the next aura event. + DF:RefreshAllVisibleFrames() + end), 30) + local debuffMax = settingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Debuffs"], 0, 8, 1, db, "debuffMax", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + debuffMax.disableOn = function(d) return not d.showDebuffs end + Add(settingsGroup, nil, 1) + + -- Appearance Group (col2) -- mirrors Buffs; see the note there. + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + local debuffSize = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 10, 40, 1, db, "debuffSize", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffSize.disableOn = function(d) return not d.showDebuffs end + local debuffScale = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "debuffScale", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffScale.disableOn = function(d) return not d.showDebuffs end + local debuffAlpha = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.0, 1.0, 0.05, db, "debuffAlpha", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffAlpha.disableOn = function(d) return not d.showDebuffs end + Add(appearanceGroup, nil, 2) + + -- ===== IMPORTANT DEBUFFS (col2) ===== + -- Boss/role and priority debuffs already render as their OWN aura groups, and + -- those groups are declared first — so they already lead the row. Everything + -- here styles them so they also LOOK different without moving to a separate + -- placement. Every change is STRUCTURAL (region presence / group layout cell / + -- the group's init closure), so each callback must invalidate rather than + -- lightweight-reposition — same pair the Hide Duplicate Debuffs toggle uses. + local function ImportantChanged() + if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + end + local function ImportantOff(d) return not d.showDebuffs or not d.debuffImportantHighlight end + + local impGroup = GUI:CreateSettingsGroup(self.child, 280) + impGroup:AddWidget(GUI:CreateHeader(self.child, L["Important Debuffs"]), 40) + impGroup:AddWidget(GUI:CreateLabel(self.child, + L["Makes boss, role and priority debuffs stand out in the normal debuff row."], 250), 30) + local impOn = impGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Highlight Important Debuffs"], + db, "debuffImportantHighlight", ImportantChanged), 30) + impOn.disableOn = function(d) return not d.showDebuffs end + impOn.tooltip = L["Boss, role and priority debuffs already sort to the front of the row. This also makes them larger and marks them, so they read at a glance without needing their own placement."] + + -- CreateSlider(parent, label, min, max, step, db, key, callback, lightweightUpdate, + -- usePreviewMode, ...) — arg 8 is the release callback, arg 9 the per-drag-tick one + -- and arg 10 the boolean that arms it. + -- + -- ☠ NO LIGHTWEIGHT PATH ON ANY OF THE FOUR SLIDERS IN THIS SECTION, and it must + -- stay that way. Every key here feeds recStyleSig (Features/Auras.lua), which is + -- part of the STRUCTURAL signature — so each new value forces h:Rebuild: a + -- NativeBackend:teardown plus a fresh container and fresh buttons, per rendered + -- frame per visible unit. applyRecordStyle then creates a badge host frame and two + -- textures per styled button, and WoW never frees a frame. Wired to the drag tick, + -- a few seconds of dragging in a 20-man leaked frames by the thousand. The + -- "documented frame-leak case" note in AuraContainer.lua is about this path. + -- + -- The cost is that the preview moves on release rather than under the cursor. That + -- is the deliberate trade: one rebuild per adjustment is the price every other + -- structural setting pays, and it is bounded. + -- + -- ⚠ The better fix is to let badge geometry ride ApplyStyle instead of forcing a + -- rebuild — applyRecordStyle is already idempotent and safe to re-run — but the + -- record style is captured as an upvalue in the secure initializeFrame closure, so + -- a live read has to be plumbed through first. That is engine work, not a slider + -- change, and narrowing the signature WITHOUT it would leave these sliders writing + -- to the DB while nothing on screen moves. + local impScale = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Size Step"], 1.0, 2.0, 0.05, + db, "debuffImportantScale", ImportantChanged), 55) + impScale.disableOn = ImportantOff + impScale.tooltip = L["How much larger an important debuff renders. 1.00 keeps it the same size as the rest of the row."] + + local impBadge = impGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Corner Marker"], + db, "debuffImportantBadge", ImportantChanged), 30) + impBadge.disableOn = ImportantOff + impBadge.tooltip = L["A small marker on the corner of the icon. It survives being shrunk better than a colour change, and it does not compete with the dispel border."] + + local impBadgeSize = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Marker Size"], 6, 20, 1, + db, "debuffImportantBadgeSize", ImportantChanged), 55) + impBadgeSize.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end + + -- hasAlpha=false, and NO lightweight path: a colour change here rebuilds the + -- group (the tint is baked at initializeFrame), so there is nothing cheaper to + -- run on drag. Signature is (parent, label, db, key, hasAlpha, cb, lightCb, useLight). + -- Corner + nudge. Offsets are ADDED to a built-in overhang that pushes the badge + -- out of whichever corner is picked, so 0/0 is already a sensible resting place. + local badgePoints = { TOPRIGHT = L["Top Right"], TOPLEFT = L["Top Left"], + BOTTOMRIGHT = L["Bottom Right"], BOTTOMLEFT = L["Bottom Left"] } + local impBadgePt = impGroup:AddWidget(GUI:CreateDropdown(self.child, L["Marker Corner"], + badgePoints, db, "debuffImportantBadgePoint", ImportantChanged), 55) + impBadgePt.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end + + local impBadgeX = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Marker Offset X"], -20, 20, 1, + db, "debuffImportantBadgeX", ImportantChanged), 55) + impBadgeX.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end + + local impBadgeY = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Marker Offset Y"], -20, 20, 1, + db, "debuffImportantBadgeY", ImportantChanged), 55) + impBadgeY.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end + + local impBadgeCol = impGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Marker Color"], + db, "debuffImportantBadgeColor", false, ImportantChanged, nil, false), 35) + impBadgeCol.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end + + local impMarkCol = impGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Marker Symbol Color"], + db, "debuffImportantMarkColor", false, ImportantChanged, nil, false), 35) + impMarkCol.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end + Add(impGroup, nil, 2) + + -- Layout Group (col1) + local gridGroup = GUI:CreateSettingsGroup(self.child, 280) + gridGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) + local debuffWrap = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Per Row"], 1, 8, 1, db, "debuffWrap", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffWrap.disableOn = function(d) return not d.showDebuffs end + local debuffPaddingX = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing X"], -5, 10, 1, db, "debuffPaddingX", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffPaddingX.disableOn = function(d) return not d.showDebuffs end + local debuffPaddingY = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing Y"], -5, 10, 1, db, "debuffPaddingY", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffPaddingY.disableOn = function(d) return not d.showDebuffs end + Add(gridGroup, nil, 1) + -- Position Group (col2) + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + local debuffAnchor = positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "debuffAnchor", nil), 55) + debuffAnchor.disableOn = function(d) return not d.showDebuffs end + local debuffGrowth = positionGroup:AddWidget(GUI:CreateGrowthControl(self.child, db, "debuffGrowth", nil), 155) + debuffGrowth.disableOn = function(d) return not d.showDebuffs end + local debuffOffsetX = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "debuffOffsetX", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffOffsetX.disableOn = function(d) return not d.showDebuffs end + local debuffOffsetY = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "debuffOffsetY", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) + debuffOffsetY.disableOn = function(d) return not d.showDebuffs end + Add(positionGroup, nil, 1) + + local function InvalidateAndUpdate() + DF.debuffBorderCurve = nil + DF:UpdateAllFrames() + end + + -- Border Group (col1) + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + -- Full border toolkit via the unified helper (Stage 5.5 Phase 2). When + -- "Color by Dispel Type" (below) is ON, the border is forced SOLID and + -- recoloured per dispel type, so Style/Colour/Gradient here only take + -- effect when it's OFF (Size/Inset always apply). Border Animation is + -- intentionally omitted (same FPS rationale as the buff row). + GUI:CreateBorderControls(borderGroup, db, "debuff", { + parent = self.child, + include = { inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true }, + sizeMin = 0, sizeMax = 8, sizeStep = 1, + fullUpdate = function() if DF.UpdateAllFrames then DF:UpdateAllFrames() end end, + lightUpdate = function() DF:LightweightUpdateAuraBorder("debuff") end, + lightColors = function() DF:LightweightUpdateAuraBorder("debuff") end, + refreshStates = function() self:RefreshStates() end, + disableWhen = function(d) return not d.showDebuffs end, + }) + -- These two are added to the box BY HAND, so the toolkit's disableWhen + -- doesn't reach them — they carry the Debuffs-off grey themselves or the + -- box would half-grey. + local colorByType = borderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Dispel Type"], db, "debuffBorderColorByType", InvalidateAndUpdate), 30) + colorByType.disableOn = function(d) return not d.showDebuffs or not d.debuffShowBorder end + -- 12.1 rows: the native dispel ring's inset (+ inward / - outward halo; the + -- ring geometry is ours even though Blizzard tints it). Live via restyle. + local dispelInset = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Dispel Border Inset"], -8, 8, 1, db, "debuffDispelBorderInset", nil, function() DF:LightweightUpdateAuraBorder("debuff") end, true), 55) + dispelInset.disableOn = function(d) return not d.showDebuffs or not d.debuffBorderColorByType end + dispelInset.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end + dispelInset.tooltip = L["How far the dispel-type ring sits inside the icon edge. Negative values push it outward into a halo around the icon instead."] + -- Colors-page link right under "Color by Dispel Type": the dispel-type palette + -- lives on the account-wide Colors page (one shared set, also used by the Dispel + -- Overlay). Co-located with its toggle so it's obvious where to edit the colours. + local dispelColorsLink = GUI:CreateDispelColorsPageLink(self.child, 260) + borderGroup:AddWidget(dispelColorsLink, (dispelColorsLink.layoutHeight or 16) + 2) + -- Column 2 leads with Border and then runs the whole duration family -- + -- Duration, Duration Bar, Bar Style -- so the three boxes that configure + -- one thing sit together instead of being split across the page. + Add(borderGroup, nil, 2) + + -- Stack Count Group (col1) + local stackCountGroup = GUI:CreateSettingsGroup(self.child, 280) + stackCountGroup:AddWidget(GUI:CreateHeader(self.child, L["Stack Count"]), 40) + stackCountGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "debuffStackFont", function() DF:LightweightUpdateAuraStackText("debuff") end), 55) + stackCountGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "debuffStackScale", nil, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 55) + stackCountGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "debuffStackOutline", function() DF:LightweightUpdateAuraStackText("debuff") end), 55) + stackCountGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "debuffStackOutline", function() DF:LightweightUpdateAuraStackText("debuff") end), 30) + stackCountGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "debuffStackAnchor", function() DF:LightweightUpdateAuraStackText("debuff") end), 55) + stackCountGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "debuffStackX", nil, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 55) + stackCountGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "debuffStackY", nil, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 55) + stackCountGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "debuffStackColor", false, function() DF:LightweightUpdateAuraStackText("debuff") end, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 30) + -- (No "Min Stacks to Show" — see the Buffs page for why it cannot exist on 12.1.) + -- Grey the whole group when Debuffs are off, matching Settings/Position/Grid. + stackCountGroup.disableChildrenOn = function(d) return not d.showDebuffs end + Add(stackCountGroup, nil, 1) + + -- Dispel Text Group (col1, under Stack Count) — the dispel-type letters + -- ("Ma", "Po", …), engine-written per aura (12.1 factory rows only; the + -- legacy renderer has no source for them). + -- ★ 2026-07-31: no longer requires Colorblind Mode. The bind passes + -- customDispelTextMap, which takes Blizzard's direct SetText path instead of + -- the CVar-gated one (DF:GetGameDispelTextMap, Frames/Border.lua) — so the + -- old caution note and the CVar caveat in the tooltip are gone with it. + -- Renamed from "Dispel Symbol" the same day: that read as the dispel ICON, + -- which is a different native feature. DB keys stay debuffDispelSymbol*. + local symbolGroup = GUI:CreateSettingsGroup(self.child, 280) + symbolGroup:AddWidget(GUI:CreateHeader(self.child, L["Dispel Text"]), 40) + local symbolEnable = symbolGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Dispel Text"], db, "debuffDispelSymbolEnabled", function() + self:RefreshStates() + -- Region presence is structural (create-once) — full re-drive rebuilds the row. + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + end), 30) + symbolEnable.tooltip = L["Shows a short letter code on each debuff for its dispel type — Ma for Magic, Po for Poison, and so on. Uses the game's own wording for your language."] + symbolEnable.keepEnabled = true + symbolEnable.disableOn = function(d) return not d.showDebuffs end + GUI:CreateTextControls(symbolGroup, db, "debuffDispelSymbol", { + parent = self.child, + include = { color = true }, + disableOn = function(d) return not d.debuffDispelSymbolEnabled end, + onChange = function() DF:InvalidateAuraLayout() end, + onDrag = function() DF:InvalidateAuraLayout() end, + }) + symbolGroup.disableChildrenOn = function(d) return not d.showDebuffs end + symbolGroup.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end + Add(symbolGroup, nil, 1) + + -- Duration Text Group (col2) + local durationGroup = GUI:CreateSettingsGroup(self.child, 280) + durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration"]), 40) + durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "debuffShowDuration", function() + self:RefreshStates() + DF:UpdateAllFrames() + end), 30) + -- Cooldown swipe (radial time-remaining) lives with Duration Text, not Border. + durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Cooldown Swipe"], db, "debuffHideSwipe", nil), 30) + -- Icon-sized formats only (see the buff page's Duration Format note). + local debuffDurationFormatOptions = { NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], + _order = { "NUMBER", "SHORT", "PERCENT" } } + local durFormat = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Duration Format"], debuffDurationFormatOptions, db, "debuffDurationFormat", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames(); GUI:RefreshCurrentPage() end), 55) + durFormat.disableOn = function(d) return not d.debuffShowDuration end + local durFont = durationGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "debuffDurationFont", nil), 55) + durFont.disableOn = function(d) return not d.debuffShowDuration end + local durScale = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "debuffDurationScale", nil, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 55) + durScale.disableOn = function(d) return not d.debuffShowDuration end + local durOutline = durationGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "debuffDurationOutline", function() DF:LightweightUpdateAuraDurationText("debuff") end), 55) + durOutline.disableOn = function(d) return not d.debuffShowDuration end + local durShadow = durationGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "debuffDurationOutline", function() DF:LightweightUpdateAuraDurationText("debuff") end), 30) + durShadow.disableOn = function(d) return not d.debuffShowDuration end + local durAnchor = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "debuffDurationAnchor", function() DF:LightweightUpdateAuraDurationText("debuff") end), 55) + durAnchor.disableOn = function(d) return not d.debuffShowDuration end + local durX = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "debuffDurationX", nil, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 55) + durX.disableOn = function(d) return not d.debuffShowDuration end + local durY = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "debuffDurationY", nil, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 55) + durY.disableOn = function(d) return not d.debuffShowDuration end + local durColorPick = durationGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Duration Color"], db, "debuffDurationColor", false, function() DF:LightweightUpdateAuraDurationText("debuff") end, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 30) + durColorPick.disableOn = function(d) return not d.debuffShowDuration or d.debuffDurationColorByTime end + local durColor = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Time Remaining"], db, "debuffDurationColorByTime", function() self:RefreshStates(); DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) + durColor.disableOn = function(d) return not d.debuffShowDuration end + AddColorsPageLink(durationGroup, self.child) + -- Hide Above can't compose with the Percent format (see the buff page). + local durHideAbove = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Above Threshold"], db, "debuffDurationHideAboveEnabled", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) + durHideAbove.disableOn = function(d) return not d.debuffShowDuration or DF:IsPercentDurationFormat(d.debuffDurationFormat) end + local durHideAboveSlider = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Above (seconds)"], 1, 60, 1, db, "debuffDurationHideAboveThreshold", nil, function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 55) + durHideAboveSlider.disableOn = function(d) return not d.debuffShowDuration or not d.debuffDurationHideAboveEnabled or DF:IsPercentDurationFormat(d.debuffDurationFormat) end + local durHidePerm = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Duration on Permanent Auras"], db, "debuffDurationHideOnPermanent", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) + durHidePerm.disableOn = function(d) return not d.debuffShowDuration end + -- Grey the whole group when Debuffs are off (composes with the per-control + -- debuffShowDuration gates), matching Settings/Position/Grid. + durationGroup.disableChildrenOn = function(d) return not d.showDebuffs end + Add(durationGroup, nil, 2) + + -- ===== DURATION BAR ===== (12.1 factory rows only — mirrors the Buffs + -- page's block; see there for the sig-split routing note) + -- + -- The collapsible section used to carry this predicate and hide the bar + -- with itself; with the section gone the box declares it directly. + local function HideDurationBar(d) return not DF:FactoryOwnsDebuffRow(d) end + + local function DebuffBarChanged() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end + + local durBarGroup = GUI:CreateSettingsGroup(self.child, 280) + durBarGroup.hideOn = HideDurationBar + durBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Bar"]), 40) + durBarGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows a bar on each icon that drains with the aura's remaining time."], 250), 30) + local debuffBarEnable = durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Duration Bar"], db, "debuffDurationBarEnabled", function() + self:RefreshStates() + DebuffBarChanged() + end), 30) + debuffBarEnable.keepEnabled = true + debuffBarEnable.disableOn = function(d) return not d.showDebuffs end + durBarGroup.disableChildrenOn = function(d) return not d.showDebuffs or not d.debuffDurationBarEnabled end + -- Where the bar sits, then what it looks like. One box rather than two: + -- every other optional element on this page (Stack Count, Dispel Text) + -- is a single box, and splitting only this one into geometry + style + -- made the bar read as more of a feature than its neighbours while + -- taking up half of column 2. + durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, db, "debuffDurationBarPosition", DebuffBarChanged), 55) + durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 12, 1, db, "debuffDurationBarHeight", nil, DebuffBarChanged, true), 55) + durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Gap"], 0, 10, 1, db, "debuffDurationBarGap", nil, DebuffBarChanged, true), 55) + durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], DF:GetDurationBarColorModes(), db, "debuffDurationBarColorMode", function() + self:RefreshStates() + DebuffBarChanged() + end), 55) + local debuffBarTex = durBarGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "debuffDurationBarTexture", DebuffBarChanged), 55) + local debuffBarCol = durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "debuffDurationBarColor", true, DebuffBarChanged), 30) + -- A curve mode brings its own ramp texture and forces white, so these two do + -- nothing while it is selected - dim them rather than leave dead controls live. + debuffBarTex.disableOn = function(d) return DF:IsDurationBarCurveMode(d.debuffDurationBarColorMode) end + debuffBarCol.disableOn = debuffBarTex.disableOn + durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "debuffDurationBarBGColor", true, DebuffBarChanged), 30) + durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "debuffDurationBarReverseFill", DebuffBarChanged), 30) + Add(durBarGroup, nil, 2) + + -- See Also links + -- ======================================== + -- ORDER & LIMITS (moved here from the old Aura Filters page) + -- ======================================== + -- Ordering and the duration cap act on what already passed the filters, so + -- they live with the bar. Which debuff categories are active is on Aura + -- Filters. The Dispellable MODE dropdown comes with them: it refines a + -- category rather than being one, and the category row's tooltip on the + -- Filters page points here for it. + local DebuffOrderChanged = function() + if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + end + + local debuffOrderGroup = GUI:CreateSettingsGroup(self.child, 280) + debuffOrderGroup:AddWidget(GUI:CreateHeader(self.child, L["Order & Limits"]), GUI.RowHeight.sectionHeader) + + local debuffSortOptions = { + DEFAULT = L["Default (Slot Order)"], + TIME = L["Time Remaining"], + NAME = L["Alphabetical"], + APPLIED = L["Order Applied"], + _order = { "DEFAULT", "TIME", "NAME", "APPLIED" }, + } + debuffOrderGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], debuffSortOptions, db, "directDebuffSortOrder", function() + DebuffOrderChanged() + self:RefreshStates() + end), 55) + + local dfSortMine = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["My Auras First"], db, "directDebuffSortMineFirst", DebuffOrderChanged), 30) + dfSortMine.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end + dfSortMine.disableOn = function(d) return not DF:SortOrderSupportsMineFirst(d.directDebuffSortOrder) end + dfSortMine.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] + local dfSortRev = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Order"], db, "directDebuffSortReverse", DebuffOrderChanged), 30) + dfSortRev.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end + dfSortRev.tooltip = L["Reverse the sort direction."] + + -- Which dispels count for the Dispellable Debuffs category. Greys rather + -- than hides while that category is off, so you can see what you would be + -- turning back on -- and it is inert while All Debuffs is on. + local dfDispelMode = debuffOrderGroup:AddWidget(GUI:CreateDropdown(self.child, L["Dispellable Debuffs"], { + PLAYER = L["Dispellable By Me"], + ALL = L["All Dispellable"], + ANY = L["Any Dispel Type"], + _order = { "PLAYER", "ALL", "ANY" }, + }, db, "directDebuffDispellableMode", DebuffOrderChanged), 55) + dfDispelMode.disableOn = function(d) + return d.directDebuffShowAll or not d.debuffFilterDispellable + end + dfDispelMode.tooltip = L["Dispellable By Me: only debuffs you can dispel. All Dispellable: any debuff that can be dispelled. Any Dispel Type: every debuff with a dispel type, even ones that cannot be dispelled."] + + -- Works in ALL-debuffs mode too (single maxDuration record) — only Keep + -- Important needs the category filters (boolean flags can't be negated on + -- the ALL record), so THAT toggle alone greys while All Debuffs is on. + local function HideDebuffMaxDurControls(d) + return not DF:FactoryOwnsDebuffRow(d) + end + local dfMaxDur = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Long Debuffs"], db, "debuffMaxDurationEnabled", function() + DebuffOrderChanged() + self:RefreshStates() + end), 30) + dfMaxDur.hideOn = HideDebuffMaxDurControls + dfMaxDur.tooltip = L["Hide debuffs whose total duration is longer than the threshold. Debuffs with no duration (permanent auras) are also hidden while this is on."] + local dfMaxDurSlider = debuffOrderGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Longer Than (minutes)"], 1, 30, 1, db, "debuffMaxDurationMinutes", nil, DebuffOrderChanged), 55) + dfMaxDurSlider.hideOn = HideDebuffMaxDurControls + dfMaxDurSlider.disableOn = function(d) return not d.debuffMaxDurationEnabled end + + local dfKeepImportant = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Keep important debuffs"], db, "debuffMaxDurationKeepImportant", DebuffOrderChanged), 30) + dfKeepImportant.hideOn = HideDebuffMaxDurControls + dfKeepImportant.disableOn = function(d) + return d.directDebuffShowAll or not d.debuffMaxDurationEnabled + end + dfKeepImportant.tooltip = L["Boss, Role, and Priority debuffs stay visible even when their duration is over the threshold."] + Add(debuffOrderGroup, nil, 1) + + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_filterdesigner", label = L["Aura Filters"]}, + {pageId = "display_tooltips", label = L["Debuff Tooltips"]}, + {pageId = "general_integrations", label = L["Integrations"]}, + {pageId = "auras_dispel", label = L["Dispel Overlay"]}, + }), 30, "both") + end) + + + -- Auras > Missing Buffs + local pageMissingBuffs = CreateSubTab("auras", "auras_missingbuffs", L["Missing Buffs"]) + BuildPage(pageMissingBuffs, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"missingBuff"}, L["Missing Buffs"], "auras_missingbuffs"), 25, 2) + + + -- Dependent controls GREY OUT (disabled-in-place) when the feature is off. + local function HideMissingBuffOptions(d) + return not d.missingBuffIconEnabled + end + + -- Manual-mode buffs HIDE when auto-detect is on (variant gate); they GREY + -- via the group's disableChildrenOn when the feature itself is disabled. + local function HideManualBuffVariant(d) + return d.missingBuffClassDetection + end + + -- 12.1 factory path: settings apply through the version-gated drive, so a + -- change must bump the aura layout version (InvalidateAuraLayout re-drives + -- every factory widget, missing-buff strip included). Legacy path unchanged. + local function refreshMissing() + if DF.FactoryOwnsMissingBuff and DF:FactoryOwnsMissingBuff(db) then + DF:InvalidateAuraLayout() + end + if DF.UpdateAllMissingBuffIcons then DF:UpdateAllMissingBuffIcons() end + end + + local anchorOptions = { + ["TOPLEFT"]= L["Top Left"], ["TOP"]= L["Top"], ["TOPRIGHT"]= L["Top Right"], + ["LEFT"]= L["Left"], ["CENTER"]= L["Center"], ["RIGHT"]= L["Right"], + ["BOTTOMLEFT"]= L["Bottom Left"], ["BOTTOM"]= L["Bottom"], ["BOTTOMRIGHT"]= L["Bottom Right"], + } + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows icon when party members are missing raid buffs."], 250), 30) + -- 12.1 (factory path): the read-free widget works in combat + Mythic+ and + -- shows EVERY tracked-and-missing buff (the legacy "first missing only" + -- priority pick needed a cross-aura read). Legacy path keeps the caveat. + local mbOwns = DF.FactoryOwnsMissingBuff and DF:FactoryOwnsMissingBuff(db) + local mPlusWarn = GUI:CreateInfoBanner(self.child, { tone = mbOwns and "info" or "caution" }) + mPlusWarn:SetText(mbOwns + and L["Updates instantly, including in combat and Mythic+. Each tracked buff that is missing shows its own icon."] + or L["Does NOT work in Mythic+ keystones. In combat, results may be slightly delayed."]) + settingsGroup:AddWidget(mPlusWarn, 60) + local missingBuffEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Missing Buff Icon"], db, "missingBuffIconEnabled", function() + self:RefreshStates() + refreshMissing() + end), 30) + missingBuffEnable.keepEnabled = true + settingsGroup.disableChildrenOn = HideMissingBuffOptions + local mbAutoDetect = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Auto-detect (your class's buff)"], db, "missingBuffClassDetection", function() + self:RefreshStates() + refreshMissing() + end), 30) + mbAutoDetect.tooltip = L["Watches whichever raid buff your own class provides, and follows you when you change character. Turn it off to pick the buffs to watch by hand below."] + local mbHideFromBar = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Raid Buffs from Buff Bar"], db, "missingBuffHideFromBar", function() + -- Factory: the exclusion is a structural candidate-filter on the BUFF row — + -- refreshMissing's InvalidateAuraLayout re-drives it (sig change -> Rebuild). + refreshMissing() + DF:UpdateAllAuras() + end), 30) + mbHideFromBar.tooltip = L["Stops the raid buffs tracked here from also taking up a slot in the normal buff row, so the missing-buff icon is the only place they appear."] + -- (No Debug Mode checkbox: its trace narrated the legacy UnitHasBuff scan, which + -- never runs on the read-free 12.1 widget -- presence is never known to Lua, so + -- there is nothing to print. Removed 2026-07-25 as its own comment long proposed.) + Add(settingsGroup, nil, 1) + + -- ===== BUFFS TO CHECK GROUP (Column 1) ===== + local buffsGroup = GUI:CreateSettingsGroup(self.child, 280) + buffsGroup:AddWidget(GUI:CreateHeader(self.child, L["Buffs to Check (Manual Mode)"]), 40) + buffsGroup:AddWidget(GUI:CreateLabel(self.child, L["When auto-detect is OFF, select which raid buffs to monitor manually."], 250), 35) + buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Arcane Intellect (Mage)"], db, "missingBuffCheckIntellect", function() + refreshMissing() + end), 30) + buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Power Word: Fortitude (Priest)"], db, "missingBuffCheckStamina", function() + refreshMissing() + end), 30) + buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Battle Shout (Warrior)"], db, "missingBuffCheckAttackPower", function() + refreshMissing() + end), 30) + buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Mark of the Wild (Druid)"], db, "missingBuffCheckVersatility", function() + refreshMissing() + end), 30) + buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Skyfury (Shaman)"], db, "missingBuffCheckSkyfury", function() + refreshMissing() + end), 30) + buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Blessing of the Bronze (Evoker)"], db, "missingBuffCheckBronze", function() + refreshMissing() + end), 30) + buffsGroup.hideOn = HideManualBuffVariant + buffsGroup.disableChildrenOn = HideMissingBuffOptions + Add(buffsGroup, nil, 1) + + -- ===== APPEARANCE GROUP (Column 2) ===== + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + appearanceGroup.disableChildrenOn = HideMissingBuffOptions + appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 12, 48, 1, db, "missingBuffIconSize", function() + refreshMissing() + end, function() DF:LightweightUpdateMissingBuff() end, true), 55) + appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 3.0, 0.1, db, "missingBuffIconScale", function() + refreshMissing() + end, function() DF:LightweightUpdateMissingBuff() end, true), 55) + appearanceGroup:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "missingBuffIconFrameLevel", function() + refreshMissing() + end, function() DF:LightweightUpdateFrameLevel("missingBuff") end, true)), 55) + Add(appearanceGroup, nil, 2) + + -- ===== POSITION GROUP (Column 1) ===== + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + positionGroup.disableChildrenOn = HideMissingBuffOptions + positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "missingBuffIconAnchor", function() + refreshMissing() + end), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "missingBuffIconX", function() + refreshMissing() + end, function() DF:LightweightUpdateMissingBuff() end, true), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "missingBuffIconY", function() + refreshMissing() + end, function() DF:LightweightUpdateMissingBuff() end, true), 55) + Add(positionGroup, nil, 1) + + -- ===== BORDER GROUP (Column 2) ===== + -- Stage 4.1: hand-rolled border block replaced by the unified helper. + -- include set tailored for a "needs attention" alert: alpha / inset / + -- offset / blendMode / gradient / shadow / animate (matches the + -- Defensive Icon — Border Offset nudges the band relative to the icon). + -- Class/Role colour offered too: the missing-buff icon sits on a unit + -- frame, so its border can communicate WHOSE buff is missing at a glance. + -- Skipped: colour-by-time / colour-by-type (no aura-state context here). + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + GUI:CreateBorderControls(borderGroup, db, "missingBuffIcon", { + parent = self.child, + include = { alpha = true, inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, animate = true, + classColor = true, roleColor = true }, + fullUpdate = function() refreshMissing() end, + lightUpdate = function() DF:LightweightUpdateMissingBuff() end, + lightColors = function() DF:LightweightUpdateMissingBuffBorderColor() end, + refreshStates = function() self:RefreshStates() end, + sizeMin = 0, sizeMax = 6, sizeStep = 1, -- 0 = animation-only (no solid edge) + }) + -- No hideWhen: the group gate below is what handles the feature being + -- off, and it GREYS like every other box on this page. (This call used to + -- pass both, so the controls vanished before the grey could show.) + borderGroup.disableChildrenOn = HideMissingBuffOptions + Add(borderGroup, nil, 2) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_buffs", label = L["Buffs"]}, + }), 30, "both") + end) + + -- Auras > Defensive Icon + local pageDefensiveIcon = CreateSubTab("auras", "auras_defensiveicon", L["Defensive Icon"]) + -- 12.1: defensive icons now render through DF.AuraContainer (native BIG_DEFENSIVE / + -- EXTERNAL_DEFENSIVE filters); the legacy path stays as a secret-hardened fallback, so + -- the page is usable — no whole-page banner, and nothing is blocked (frame level IS + -- honored, via the container's frameLevelOffset). Known gaps the factory doesn't + -- reproduce yet (not cleanly addressable, left as-is): border animation (inlined in the + -- shared border helper) and CENTER growth (a dropdown option that falls back to RIGHT). + BuildPage(pageDefensiveIcon, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top. defensiveFilterSelection is an exact-key entry + -- (prefix matcher, see Profile.lua) so the category selection edited on + -- this page rides this page's Copy/Sync/Reset. It is also registered on + -- the Aura Filters page — overlap is fine, both DeepCopy the same value. + -- "defensiveBar" covers the row's Layout box (Max / Growth / Spacing / Wrap), + -- which none of the other prefixes reached. + Add(CreateCopyButton(self.child, {"defensiveIcon", "defensiveFilterSelection", "defensiveSortOrder", "defensiveDurationBar", "defensiveBar"}, L["Defensive Icon"], "auras_defensiveicon"), 25, 2) + + local anchorOptions = { + CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], + TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], + } + + -- Dependent controls GREY OUT (disabled-in-place) when the feature is off. + local function HideDefensiveIconOptions(d) + return not d.defensiveIconEnabled + end + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows an icon when party members have a defensive cooldown active (Pain Suppression, Ironbark, etc.)."], 250), 45) + local defensiveEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Defensive Icon"], db, "defensiveIconEnabled", function() + self:RefreshStates() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 30) + defensiveEnable.keepEnabled = true + settingsGroup.disableChildrenOn = HideDefensiveIconOptions + + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Cooldown Swipe"], db, "defensiveIconHideSwipe", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 30) + Add(settingsGroup, nil, 1) + + -- ===== LAYOUT GROUP (Column 1) ===== + local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) + layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) + layoutGroup:AddWidget(GUI:CreateLabel(self.child, L["Controls how multiple defensive icons are arranged."], 250), 45) + layoutGroup.disableChildrenOn = HideDefensiveIconOptions + + layoutGroup:AddWidget(GUI:CreateGrowthControl(self.child, db, "defensiveBarGrowth", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 155) + layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Icons"], 1, 5, 1, db, "defensiveBarMax", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, nil, true), 55) + + -- Native rows only — the legacy fallback keeps its own fixed order. + local defSortOptions = { + DEFAULT = L["Default (Slot Order)"], + TIME = L["Most Urgent"], + EXTERNALS = L["Externals First"], + } + local defSortDrop = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], defSortOptions, db, "defensiveSortOrder", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 55) + defSortDrop.hideOn = function(d) return not DF:FactoryOwnsDefensiveRow(d) end + defSortDrop.tooltip = L["Externals First: defensives cast on this player by others show first, their own last. Most Urgent: soonest to expire first."] + + local defWrap = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Per Row"], 1, 5, 1, db, "defensiveBarWrap", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, nil, true), 55) + -- Greys out on vertical-primary growth, where the native row-primary flow renders a + -- single column and there is nothing for a per-row count to do. Normal contextual + -- state via the grey seam, NOT a 12.1 frost — the control works horizontally, and the + -- blocked registry is for things the game genuinely cannot do. Mirrors the Buffs page, + -- including its 68914 re-verification of the flow-layout options. + defWrap.disableOn = function(d) + local g = d.defensiveBarGrowth or "" + -- Vertical-primary AND vertical-centred growth both render a single column. + return DF:FactoryOwnsDefensiveRow(d) and (g:sub(1, 2) == "UP" or g:sub(1, 4) == "DOWN" + or g == "CENTER_LEFT" or g == "CENTER_RIGHT") + end + + layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], -10, 10, 1, db, "defensiveBarSpacing", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) + + Add(layoutGroup, nil, 1) + + -- ===== APPEARANCE GROUP (Column 2) ===== + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + appearanceGroup.disableChildrenOn = HideDefensiveIconOptions + + appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 12, 48, 1, db, "defensiveIconSize", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) + + appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 4.0, 0.1, db, "defensiveIconScale", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) + + appearanceGroup:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "defensiveIconFrameLevel", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, function() DF:LightweightUpdateFrameLevel("defensive") end, true)), 55) + + Add(appearanceGroup, nil, 2) + + -- ===== POSITION GROUP (Column 1) ===== + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + positionGroup.disableChildrenOn = HideDefensiveIconOptions + + positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "defensiveIconAnchor", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 55) + + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "defensiveIconX", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) + + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "defensiveIconY", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) + Add(positionGroup, nil, 1) + + -- ===== BORDER GROUP (Column 2) ===== + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + + -- Canonical border controls via the unified helper. include opts in + -- inset / offset / blendMode / gradient / shadow on top of the + -- always-present Show / Style / Texture / Size / Colour. Inset moves + -- the border edges inward (positive) or outward (negative) relative + -- to the icon's bounds — independent of borderSize (thickness) and + -- independent of the artwork's own inset. + GUI:CreateBorderControls(borderGroup, db, "defensiveIcon", { + parent = self.child, + -- Class/Role colour makes sense here: at a glance, the border + -- communicates WHO is using the defensive cooldown (their class + -- or role) without the user having to read the icon. (Animation is + -- not offered: the defensive icon is a container button, and 12.1 + -- forbids driving its border while auras are secret — see + -- AuraContainer's animation chokepoint.) + include = { inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + classColor = true, roleColor = true }, + fullUpdate = function() if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end end, + lightUpdate = function() DF:LightweightUpdateDefensiveIcons() end, + lightColors = function() DF:LightweightUpdateDefensiveIconColors() end, + refreshStates = function() self:RefreshStates() end, + }) + -- No hideWhen: the group gate below is what handles the feature being + -- off, and it GREYS like every other box on this page. (This call used to + -- pass both, so the controls vanished before the grey could show.) + borderGroup.disableChildrenOn = HideDefensiveIconOptions + Add(borderGroup, nil, 2) + + -- ===== DEFENSIVE FILTERS GROUP (Column 2) ===== + -- Category filter selection for the defensive row (Filter Registry + -- presets + custom filters). Mirrors the Aura Filters page's buff + -- selection list. Each row toggles a key inside + -- db.defensiveFilterSelection — always mutate the inner tables in + -- place (the aura pipeline holds references to them; never reassign). + -- No Show All / Only Mine here: the defensive row resolves with + -- showAll hard-false and has no such keys (see BuildDefensiveRowConfig). + do + local filterGroup = GUI:CreateSettingsGroup(self.child, 280) + filterGroup:AddWidget(GUI:CreateHeader(self.child, L["Defensive Filters"]), 40) + filterGroup.disableChildrenOn = HideDefensiveIconOptions + + filterGroup:AddWidget(GUI:CreateLabel(self.child, "|cff888888" .. L["Enabled filters are combined \226\128\148 buffs matching any selected filter will be shown."] .. "|r", 250), 35) + + -- Rebuild the native filter strings and re-drive the container rows + -- (same pair as the Aura Filters page's DirectFilterChanged — this + -- page has no local equivalent). + local function DefensiveFilterChanged() + if DF.RebuildDirectFilterStrings then + DF:RebuildDirectFilterStrings() + end + if DF.InvalidateAuraLayout then + DF:InvalidateAuraLayout() + end + end + + local R = DF.FilterRegistry + local function SelectionCheckbox(labelText, getSel, setSel) + return filterGroup:AddWidget(GUI:CreateCheckbox(self.child, labelText, nil, nil, DefensiveFilterChanged, getSel, setSel), 30) + end + + for _, cat in ipairs(R.Categories) do + local key = cat.key + local enabled, total = R:PresetCounts(key) + local counts = R:IsPresetModified(key) + and format("(%d/%d, %s)", enabled, total, L["Modified"]) + or format("(%d/%d)", enabled, total) + SelectionCheckbox(format("%s |cff888888%s|r", L[cat.name], counts), + function() return db.defensiveFilterSelection.presets[key] or false end, + function(v) db.defensiveFilterSelection.presets[key] = v or nil end) + end + + -- Custom filters, sorted by name for a stable order (the store is id-keyed) + local sortedCustoms = {} + for cfId in pairs(R:GetStore().customFilters) do + sortedCustoms[#sortedCustoms + 1] = cfId + end + table.sort(sortedCustoms, function(a, b) + local fa, fb = R:GetCustomFilter(a), R:GetCustomFilter(b) + local na, nb = (fa and fa.name or ""), (fb and fb.name or "") + if na ~= nb then return na < nb end + return a < b + end) + for _, cfId in ipairs(sortedCustoms) do + local f = R:GetCustomFilter(cfId) + SelectionCheckbox(format("%s |c%s(%s)|r", f.name or cfId, GUI:ToneHex("info"), L["Custom"]), + function() return db.defensiveFilterSelection.customs[cfId] or false end, + function(v) db.defensiveFilterSelection.customs[cfId] = v or nil end) + end + + -- Complement bucket: buffs that belong to no category + SelectionCheckbox(L["Uncategorised Buffs"], + function() return db.defensiveFilterSelection.uncategorised end, + function(v) db.defensiveFilterSelection.uncategorised = v and true or false end) + + local defManage = filterGroup:AddWidget(GUI:CreateButton(self.child, L["Manage Filters"], 140, 22, function() + if GUI.SelectTab and GUI.Pages and GUI.Pages["auras_filterdesigner"] then + GUI.SelectTab("auras_filterdesigner") + end + end), 30) + defManage.disableOn = function() return not (GUI.Pages and GUI.Pages["auras_filterdesigner"]) end + + -- The page build is cached across tab switches, but preset counts and + -- the custom-filter list can change while this page is hidden (Filter + -- Designer edits). On show, invalidate the page cache when the registry + -- signature moved so RefreshCached() rebuilds fresh rows instead of + -- serving stale ones (same idiom as the Aura Filters page). + local function RegistrySignature() + local parts = {} + for _, cat in ipairs(R.Categories) do + local enabled, total = R:PresetCounts(cat.key) + parts[#parts + 1] = format("%s:%d/%d%s", cat.key, enabled, total, + R:IsPresetModified(cat.key) and "*" or "") + end + for cfId, f in pairs(R:GetStore().customFilters) do + parts[#parts + 1] = cfId .. "=" .. (f.name or "") + end + table.sort(parts) + return table.concat(parts, ";") + end + self.dfDefFilterSignature = RegistrySignature() + if not self.dfDefFilterSigHooked then + self.dfDefFilterSigHooked = true + self:HookScript("OnShow", function(page) + if page.dfDefFilterSignature ~= RegistrySignature() then + page:Invalidate() + end + end) + end + + Add(filterGroup, nil, 2) + end + + -- ===== DURATION GROUP (Column 1) ===== + local durationGroup = GUI:CreateSettingsGroup(self.child, 280) + durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Text"]), 40) + + durationGroup.disableChildrenOn = HideDefensiveIconOptions + durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "defensiveIconShowDuration", function() + self:RefreshStates() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 30) + + -- Sub-controls HIDE when Show Duration is off (variant gate); they GREY + -- via the group's disableChildrenOn when the feature itself is disabled. + local function HideDefensiveDurationOptions(d) + return not d.defensiveIconShowDuration + end + + -- Duration Format (PTR-7 #5): previously hardcoded NUMBER; icon-sized + -- formats only (see the buff page's Duration Format note). No Hide Above + -- on this page, so no percent-grey needed. + local defDurFormatOptions = { NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], + _order = { "NUMBER", "SHORT", "PERCENT" } } + local defDurFormat = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Duration Format"], defDurFormatOptions, db, "defensiveIconDurationFormat", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 55) + defDurFormat.hideOn = HideDefensiveDurationOptions + + -- Shared TextStyle control block (font/scale/outline/shadow/colour/anchor/ + -- offsets/justify). The offsets/anchor honor the existing defensiveIconDurationX/Y + -- keys (previously config-only); the static colour greys while Color-by-Time owns it. + GUI:CreateTextControls(durationGroup, db, "defensiveIconDuration", { + parent = self.child, + include = { color = true }, + colorLabel = L["Duration Color"], + hideOn = HideDefensiveDurationOptions, + colorDisableOn = function(d) return d.defensiveIconDurationColorByTime end, + onChange = function() if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end end, + onDrag = function() DF:LightweightUpdateDefensiveIcons() end, + }) + + local diDurColorByTime = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Time Remaining"], db, "defensiveIconDurationColorByTime", function() + self:RefreshStates() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 30) + diDurColorByTime.hideOn = HideDefensiveDurationOptions + local diColorsLink = AddColorsPageLink(durationGroup, self.child) + diColorsLink.hideOn = HideDefensiveDurationOptions + + local diDurHidePerm = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Duration on Permanent Auras"], db, "defensiveIconDurationHideOnPermanent", function() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end), 30) + diDurHidePerm.hideOn = HideDefensiveDurationOptions + + Add(durationGroup, nil, 1) + + -- (The old "Duration Position" group is gone: CreateTextControls above already + -- renders Anchor + Offset X/Y on the same defensiveIconDurationX/Y keys — the + -- separate group was a duplicate left behind by the TextStyle conversion.) + + -- ===== DURATION BAR GROUP (Column 1) ===== (12.1 factory rows only — + -- mirrors the Buffs page's block; UpdateAllDefensiveBars bumps the layout + -- version, and the sig split routes Rebuild vs in-place restyle) + local durBarGroup = GUI:CreateSettingsGroup(self.child, 280) + durBarGroup.hideOn = function(d) return not DF:FactoryOwnsDefensiveRow(d) end + durBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Bar"]), 40) + durBarGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows a bar on each icon that drains with the aura's remaining time."], 250), 30) + local function DefBarChanged() + if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end + end + local defBarEnable = durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Duration Bar"], db, "defensiveDurationBarEnabled", function() + self:RefreshStates() + DefBarChanged() + end), 30) + defBarEnable.keepEnabled = true + defBarEnable.disableOn = HideDefensiveIconOptions + durBarGroup.disableChildrenOn = function(d) return not d.defensiveIconEnabled or not d.defensiveDurationBarEnabled end + durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, db, "defensiveDurationBarPosition", DefBarChanged), 55) + durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 12, 1, db, "defensiveDurationBarHeight", nil, DefBarChanged, true), 55) + durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Gap"], 0, 10, 1, db, "defensiveDurationBarGap", nil, DefBarChanged, true), 55) + durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], DF:GetDurationBarColorModes(), db, "defensiveDurationBarColorMode", function() + self:RefreshStates() + DefBarChanged() + end), 55) + local defBarTex = durBarGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "defensiveDurationBarTexture", DefBarChanged), 55) + local defBarCol = durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "defensiveDurationBarColor", true, DefBarChanged), 30) + -- A curve mode brings its own ramp texture and forces white, so these two do + -- nothing while it is selected - dim them rather than leave dead controls live. + defBarTex.disableOn = function(d) return DF:IsDurationBarCurveMode(d.defensiveDurationBarColorMode) end + defBarCol.disableOn = defBarTex.disableOn + durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "defensiveDurationBarBGColor", true, DefBarChanged), 30) + durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "defensiveDurationBarReverseFill", DefBarChanged), 30) + Add(durBarGroup, nil, 1) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_buffs", label = L["Buffs"]}, + {pageId = "auras_debuffs", label = L["Debuffs"]}, + {pageId = "auras_filterdesigner", label = L["Aura Filters"]}, + {pageId = "general_integrations", label = L["Integrations"]}, + }), 30, "both") + end) + + -- ======================================== + -- CATEGORY: Indicators + -- ======================================== + CreateCategory("indicators", L["Indicators"]) + + -- (Removed) Indicators > Targeted Spells. The group-frame display it + -- configured is gone - Blizzard's 2026-04-07 UnitIsUnit hotfix removed the + -- only way to tell which group member an enemy was casting at. The page had + -- already been pulled from the sidebar; this removes the page itself, its + -- api-blocked overlay, and GUI.RefreshTargetedSpellsOverlay (no callers). + -- Personal Targeted and the Targeted List below are unaffected. + + -- ============================================================ + -- Indicators > Targeted List + -- ============================================================ + -- Stacked cast-bar display showing enemy casts targeting party + -- members. Replaces the group-frame Targeted Spells icons that + -- Blizzard's 2026-04-07 UnitIsUnit hotfix permanently broke. + -- Party-only feature; raid mode shows a redirect message. + local pageTargetedList = CreateSubTab("indicators", "indicators_targetedlist", L["Targeted List"]) + BuildPage(pageTargetedList, function(self, db, Add, AddSpace, AddSyncPoint) + -- Party-only feature: show message and return if in raid mode + if GUI.SelectedMode == "raid" then + Add(GUI:CreateHeader(self.child, L["Targeted List"]), 40, "both") + Add(GUI:CreateLabel(self.child, + L["Targeted List is a Party-only feature. Switch to Party mode to configure."], + 500, {r = 0.6, g = 0.6, b = 0.6}), 60, "both") + return + end + + -- Copy button at top + Add(CreateCopyButton(self.child, {"targetedList"}, L["Targeted List"], "indicators_targetedlist"), 25, 2) + + + + local growthOptions = { UP = L["Up"], DOWN = L["Down"] } + local iconPosOptions = { LEFT = L["Left"], RIGHT = L["Right"] } + local stylePresetOptions = { + DEFAULT = L["Default"], + COMPACT = L["Compact"], + DETAILED = L["Detailed"], + MINIMAL = L["Minimal"], + } + + + local function HideTLOptions(d) return not d.targetedListEnabled end + local function HideIconOptions(d) return not d.targetedListEnabled or not d.targetedListShowIcon end + local function HideTargetNameOptions(d) return not d.targetedListEnabled or not d.targetedListShowTargetName end + + local function TargetedListUpdate() + if DF.UpdateTargetedListLayout then DF:UpdateTargetedListLayout() end + end + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, + L["Shows a bar when an enemy is casting a spell targeting a party/raid member."], 250), 35) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, + "|cff888888" .. L["To reposition: Unlock frames (/df unlock) and drag the mover."] .. "|r", 250), 30) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable"], db, "targetedListEnabled", function() + self:RefreshStates() + if DF.ToggleTargetedList then DF:ToggleTargetedList(db.targetedListEnabled) end + -- Reflect the enable change in test mode immediately (so disabling + -- hides the test display, not just the live bars). + if DF.UpdateAllTestTargetedList then DF:UpdateAllTestTargetedList() end + end), 30) + local tlImportantOnly = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Important Spells Only"], db, "targetedListImportantOnly", TargetedListUpdate), 30) + tlImportantOnly.disableOn = HideTLOptions + local tlHideOwn = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Casts Targeting You"], db, "targetedListHideOwnCasts", TargetedListUpdate), 30) + tlHideOwn.disableOn = HideTLOptions + local tlShowUntargeted = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Untargeted Casts"], db, "targetedListShowUntargeted", TargetedListUpdate), 30) + tlShowUntargeted.disableOn = HideTLOptions + local tlHideOOC = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Out-of-Combat Casts"], db, "targetedListHideOutOfCombat", TargetedListUpdate), 30) + tlHideOOC.disableOn = HideTLOptions + tlHideOOC.tooltip = L["Hides the ambient spells idle NPCs cast while standing around: casts with no target, from an enemy that is not in combat. Casts aimed at you or a group member always show, so the opening cast of a pull is never hidden."] + -- Game CVar, not a profile key — bound straight to the CVar via + -- customGet/customSet so it cannot drift out of sync. See + -- DF:SetNameplateOffscreen for why both features depend on it. + local tlOffscreen = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Offscreen Nameplates"], nil, nil, nil, + function() return DF:GetNameplateOffscreen() end, + function(val) DF:SetNameplateOffscreen(val) end), 30) + tlOffscreen.disableOn = HideTLOptions + tlOffscreen.tooltip = L["Changes the Blizzard game setting 'nameplateShowOffscreen', which decides whether enemies outside your view still get a nameplate. This feature spots casts by watching the game's enemy nameplates, so with the setting off an enemy casting behind you is missed until you turn to face it — even if you have it targeted. Note that this is a game setting, not a DandersFrames one: it applies to your whole account and changes the game's nameplates everywhere."] + local tlMaxBars = settingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Bars"], 1, 20, 1, db, "targetedListMaxBars", TargetedListUpdate, TargetedListUpdate, true), 55) + tlMaxBars.disableOn = HideTLOptions + Add(settingsGroup, nil, 1) + + + + local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) + layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Size & Spacing"]), 40) + local tlW = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Bar Width"], 120, 600, 1, db, "targetedListWidth", TargetedListUpdate, TargetedListUpdate, true), 55) + tlW.disableOn = HideTLOptions + local tlH = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Bar Height"], 14, 48, 1, db, "targetedListHeight", TargetedListUpdate, TargetedListUpdate, true), 55) + tlH.disableOn = HideTLOptions + local tlSpace = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], 0, 10, 1, db, "targetedListSpacing", TargetedListUpdate, TargetedListUpdate, true), 55) + tlSpace.disableOn = HideTLOptions + local tlGrowth = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], growthOptions, db, "targetedListGrowth", TargetedListUpdate), 55) + tlGrowth.disableOn = HideTLOptions + local sortOptions = { NEWEST = L["Newest First"], OLDEST = L["Oldest First"], STATIC = L["Static (No Reorder)"] } + local tlSort = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], sortOptions, db, "targetedListSortOrder", TargetedListUpdate), 55) + tlSort.disableOn = HideTLOptions + Add(layoutGroup, nil, 1) + + local presetGroup = GUI:CreateSettingsGroup(self.child, 280) + presetGroup:AddWidget(GUI:CreateHeader(self.child, L["Bar Style"]), 40) + -- Picking a preset writes a bundle of settings to db + -- (bar dimensions, show/hide toggles, font size, etc.) + -- via DF:ApplyTargetedListPreset. After the bundle is + -- applied the individual settings remain editable — + -- the preset is a one-shot "start from this configuration" + -- action, not a continuous override. + local tlPreset = presetGroup:AddWidget(GUI:CreateDropdown(self.child, L["Bar Style"], stylePresetOptions, db, "targetedListStylePreset", function() + if DF.ApplyTargetedListPreset then + DF:ApplyTargetedListPreset(db.targetedListStylePreset) + end + -- Also refresh GUI widgets so users see the preset's + -- values reflected in the other sliders/checkboxes. + if GUI and GUI.RefreshCurrentPage then + GUI:RefreshCurrentPage() + end + TargetedListUpdate() + end), 55) + tlPreset.disableOn = HideTLOptions + local tlTexture = presetGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "targetedListTexture", TargetedListUpdate), 55) + tlTexture.disableOn = HideTLOptions + local tlBgAlpha = presetGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0, 1, 0.05, db, "targetedListBackgroundAlpha", TargetedListUpdate, TargetedListUpdate, true), 55) + tlBgAlpha.disableOn = HideTLOptions + Add(presetGroup, nil, 2) + + + + local colorGroup = GUI:CreateSettingsGroup(self.child, 280) + colorGroup:AddWidget(GUI:CreateHeader(self.child, L["Bar Color"]), 40) + local tlInterColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Interruptible Color"], db, "targetedListInterruptibleColor", true, TargetedListUpdate, function() if DF.LightweightUpdateTargetedListBarColor then DF:LightweightUpdateTargetedListBarColor() end end, true), 35) + tlInterColor.disableOn = HideTLOptions + local tlUninterColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Uninterruptible Color"], db, "targetedListUninterruptibleColor", true, TargetedListUpdate, function() if DF.LightweightUpdateTargetedListBarColor then DF:LightweightUpdateTargetedListBarColor() end end, true), 35) + tlUninterColor.disableOn = HideTLOptions + local tlSelfTargetEnabled = colorGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Self-Target Color"], db, "targetedListSelfTargetColorEnabled", function() + self:RefreshStates() + TargetedListUpdate() + end), 30) + tlSelfTargetEnabled.disableOn = HideTLOptions + tlSelfTargetEnabled.tooltip = L["Highlight the bar when the enemy is casting at you."] + local function HideSelfTargetOptions(d) return not d.targetedListEnabled or not d.targetedListSelfTargetColorEnabled end + local tlSelfTargetColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Self-Target Color"], db, "targetedListSelfTargetColor", true, TargetedListUpdate, nil, true), 35) + tlSelfTargetColor.disableOn = HideSelfTargetOptions + local tlHighlight = colorGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Highlight Important Spells"], db, "targetedListHighlightImportant", function() + self:RefreshStates() + TargetedListUpdate() + end), 30) + tlHighlight.disableOn = HideTLOptions + local function HideHighlightOptions(d) return not d.targetedListEnabled or not d.targetedListHighlightImportant end + local tlHighlightColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Highlight Color"], db, "targetedListHighlightColor", true, TargetedListUpdate, function() if DF.LightweightUpdateTargetedListHighlightColor then DF:LightweightUpdateTargetedListHighlightColor() end end, true), 35) + tlHighlightColor.disableOn = HideHighlightOptions + local tlResetColors = colorGroup:AddWidget(GUI:CreateButton(self.child, L["Reset Colors to Default"], 200, 24, function() + db.targetedListInterruptibleColor = {r = 1, g = 0.494, b = 0.137, a = 1} + db.targetedListUninterruptibleColor = {r = 0.8, g = 0.302, b = 0.302, a = 1} + db.targetedListSelfTargetColor = {r = 0.02, g = 0.776, b = 0.4, a = 0.2} + db.targetedListHighlightColor = {r = 1, g = 0.8, b = 0, a = 1} + db.targetedListBorderColor = {r = 0.18, g = 0.18, b = 0.18, a = 1} + -- Refresh color swatches + if tlInterColor.UpdateSwatch then tlInterColor:UpdateSwatch() end + if tlUninterColor.UpdateSwatch then tlUninterColor:UpdateSwatch() end + if tlSelfTargetColor.UpdateSwatch then tlSelfTargetColor:UpdateSwatch() end + if tlHighlightColor.UpdateSwatch then tlHighlightColor:UpdateSwatch() end + TargetedListUpdate() + self:RefreshStates() + end), 30) + tlResetColors.disableOn = HideTLOptions + Add(colorGroup, nil, 2) + + -- Border gets its own box, after Appearance (Bar Style + Bar Color) + -- and before the element extras — the page-layout standard's column 2 + -- order. It used to be appended to the Bar Style box, where it read as + -- part of the style preset it has nothing to do with. + -- + -- Targeted List is a list view (N bars), so animate is deliberately + -- skipped (per-bar animation would be visual noise + a perf hit). + -- class/role colour skipped because the bars represent SPELLS, not + -- units. colour-by-time / colour-by-type also skipped (no aura state). + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + GUI:CreateBorderControls(borderGroup, db, "targetedList", { + parent = self.child, + include = { alpha = true, inset = true, blendMode = true, + gradient = true, shadow = true }, + fullUpdate = TargetedListUpdate, + lightUpdate = TargetedListUpdate, + lightColors = function() if DF.LightweightUpdateTargetedListBorderColor then DF:LightweightUpdateTargetedListBorderColor() end end, + refreshStates = function() self:RefreshStates() end, + sizeMin = 1, sizeMax = 6, sizeStep = 1, + -- GREY, not hide: the rest of this page greys via + -- disableOn = HideTLOptions, and the border block was the one + -- thing that vanished instead. + disableWhen = HideTLOptions, + }) + Add(borderGroup, nil, 2) + + local iconGroup = GUI:CreateSettingsGroup(self.child, 280) + iconGroup:AddWidget(GUI:CreateHeader(self.child, L["Icon"]), 40) + local tlShowIcon = iconGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Icon"], db, "targetedListShowIcon", function() + self:RefreshStates() + TargetedListUpdate() + end), 30) + tlShowIcon.disableOn = HideTLOptions + local tlIconPos = iconGroup:AddWidget(GUI:CreateDropdown(self.child, L["Icon Position"], iconPosOptions, db, "targetedListIconPosition", TargetedListUpdate), 55) + tlIconPos.disableOn = HideIconOptions + local tlZoom = iconGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Zoom Icon"], db, "targetedListZoomIcon", TargetedListUpdate), 30) + tlZoom.disableOn = HideIconOptions + Add(iconGroup, nil, 2) + + + + local textToggleGroup = GUI:CreateSettingsGroup(self.child, 280) + textToggleGroup:AddWidget(GUI:CreateHeader(self.child, L["Show Text"]), 40) + local tlShowSpellName = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Spell Name"], db, "targetedListShowSpellName", TargetedListUpdate), 30) + tlShowSpellName.disableOn = HideTLOptions + local tlShowTargetName = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Target Name"], db, "targetedListShowTargetName", function() + self:RefreshStates() + TargetedListUpdate() + end), 30) + tlShowTargetName.disableOn = HideTLOptions + local tlShowDuration = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "targetedListShowDuration", TargetedListUpdate), 30) + tlShowDuration.disableOn = HideTLOptions + local tlClassColor = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Target Name Class Color"], db, "targetedListTargetNameClassColor", TargetedListUpdate), 30) + tlClassColor.disableOn = HideTargetNameOptions + local tlArrow = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Arrow Prefix"], db, "targetedListShowArrowPrefix", TargetedListUpdate), 30) + tlArrow.disableOn = HideTargetNameOptions + local tlArrowSuffix = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Arrow Suffix"], db, "targetedListShowArrowSuffix", TargetedListUpdate), 30) + tlArrowSuffix.disableOn = HideTargetNameOptions + Add(textToggleGroup, nil, 1) + + local fontGroup = GUI:CreateSettingsGroup(self.child, 280) + fontGroup:AddWidget(GUI:CreateHeader(self.child, L["Text Font"]), 40) + local tlFont = fontGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "targetedListFont", TargetedListUpdate), 55) + tlFont.disableOn = HideTLOptions + local tlFontSize = fontGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 8, 24, 1, db, "targetedListFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) + tlFontSize.disableOn = HideTLOptions + local tlFontOutline = fontGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "targetedListFontOutline", TargetedListUpdate), 55) + tlFontOutline.disableOn = HideTLOptions + local tlFontShadow = fontGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "targetedListFontOutline", TargetedListUpdate), 30) + tlFontShadow.disableOn = HideTLOptions + Add(fontGroup, nil, 2) + + + -- Per-element anchor + X/Y offset. Each text element + -- (spell name, target name, duration) can be independently + -- anchored to LEFT / CENTER / RIGHT within the bar's + -- progress region with a pixel offset applied on top. + + local textAnchorOptions = { LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } + local textAlignOptions = { LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } + + local spellNamePosGroup = GUI:CreateSettingsGroup(self.child, 280) + spellNamePosGroup:AddWidget(GUI:CreateHeader(self.child, L["Spell Name Position"]), 40) + local tlSNFontSize = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListSpellNameFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) + tlSNFontSize.disableOn = HideTLOptions + local tlSNWidth = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Text Width"], 0, 400, 1, db, "targetedListSpellNameWidth", TargetedListUpdate, TargetedListUpdate, true), 55) + tlSNWidth.disableOn = HideTLOptions + local tlSNAnchor = spellNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListSpellNameAnchor", TargetedListUpdate), 55) + tlSNAnchor.disableOn = HideTLOptions + local tlSNAlign = spellNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListSpellNameAlign", TargetedListUpdate), 55) + tlSNAlign.disableOn = HideTLOptions + local tlSNX = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListSpellNameX", TargetedListUpdate, TargetedListUpdate, true), 55) + tlSNX.disableOn = HideTLOptions + local tlSNY = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListSpellNameY", TargetedListUpdate, TargetedListUpdate, true), 55) + tlSNY.disableOn = HideTLOptions + Add(spellNamePosGroup, nil, 1) + + local targetNamePosGroup = GUI:CreateSettingsGroup(self.child, 280) + targetNamePosGroup:AddWidget(GUI:CreateHeader(self.child, L["Target Name Position"]), 40) + local tlTNFontSize = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListTargetNameFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) + tlTNFontSize.disableOn = HideTargetNameOptions + local tlTNWidth = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Text Width"], 0, 400, 1, db, "targetedListTargetNameWidth", TargetedListUpdate, TargetedListUpdate, true), 55) + tlTNWidth.disableOn = HideTargetNameOptions + local tlTNAnchor = targetNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListTargetNameAnchor", TargetedListUpdate), 55) + tlTNAnchor.disableOn = HideTargetNameOptions + local tlTNAlign = targetNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListTargetNameAlign", TargetedListUpdate), 55) + tlTNAlign.disableOn = HideTargetNameOptions + local tlTNX = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListTargetNameX", TargetedListUpdate, TargetedListUpdate, true), 55) + tlTNX.disableOn = HideTargetNameOptions + local tlTNY = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListTargetNameY", TargetedListUpdate, TargetedListUpdate, true), 55) + tlTNY.disableOn = HideTargetNameOptions + Add(targetNamePosGroup, nil, 2) + + local function HideDurationPosOptions(d) return not d.targetedListEnabled or not d.targetedListShowDuration end + local durationPosGroup = GUI:CreateSettingsGroup(self.child, 280) + durationPosGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Position"]), 40) + local tlDurFontSize = durationPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListDurationFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) + tlDurFontSize.disableOn = HideDurationPosOptions + local tlDurAnchor = durationPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListDurationAnchor", TargetedListUpdate), 55) + tlDurAnchor.disableOn = HideDurationPosOptions + local tlDurAlign = durationPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListDurationAlign", TargetedListUpdate), 55) + tlDurAlign.disableOn = HideDurationPosOptions + local tlDurX = durationPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListDurationX", TargetedListUpdate, TargetedListUpdate, true), 55) + tlDurX.disableOn = HideDurationPosOptions + local tlDurY = durationPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListDurationY", TargetedListUpdate, TargetedListUpdate, true), 55) + tlDurY.disableOn = HideDurationPosOptions + Add(durationPosGroup, nil, 1) + + local interruptPosGroup = GUI:CreateSettingsGroup(self.child, 280) + interruptPosGroup:AddWidget(GUI:CreateHeader(self.child, L["Interrupt Text Position"]), 40) + local tlIntFontSize = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListInterruptTextFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) + tlIntFontSize.disableOn = HideTLOptions + local tlIntWidth = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Text Width"], 0, 400, 1, db, "targetedListInterruptTextWidth", TargetedListUpdate, TargetedListUpdate, true), 55) + tlIntWidth.disableOn = HideTLOptions + local tlIntAnchor = interruptPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListInterruptTextAnchor", TargetedListUpdate), 55) + tlIntAnchor.disableOn = HideTLOptions + local tlIntAlign = interruptPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListInterruptTextAlign", TargetedListUpdate), 55) + tlIntAlign.disableOn = HideTLOptions + local tlIntX = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListInterruptTextX", TargetedListUpdate, TargetedListUpdate, true), 55) + tlIntX.disableOn = HideTLOptions + local tlIntY = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListInterruptTextY", TargetedListUpdate, TargetedListUpdate, true), 55) + tlIntY.disableOn = HideTLOptions + Add(interruptPosGroup, nil, 2) + + + + local timingGroup = GUI:CreateSettingsGroup(self.child, 280) + timingGroup:AddWidget(GUI:CreateHeader(self.child, L["Timing"]), 40) + local tlFadeOut = timingGroup:AddWidget(GUI:CreateSlider(self.child, L["Fade Out Duration"], 0, 1, 0.05, db, "targetedListFadeOutDuration", TargetedListUpdate, TargetedListUpdate, true), 55) + tlFadeOut.disableOn = HideTLOptions + local tlFlashDur = timingGroup:AddWidget(GUI:CreateSlider(self.child, L["Interrupted Flash Duration"], 0, 2, 0.1, db, "targetedListInterruptedFlashDuration", TargetedListUpdate, TargetedListUpdate, true), 55) + tlFlashDur.disableOn = HideTLOptions + Add(timingGroup, nil, 1) + + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + -- DEPRECATED-TARGETED-SPELLS: link dropped with the sidebar row. + {pageId = "indicators_personal_targeted", label = L["Personal Targeted"]}, + }), 30, "both") + end) + + -- Indicators > Personal Targeted Spells (center of screen display for player) + local pagePersonalTargeted = CreateSubTab("indicators", "indicators_personal_targeted", L["Personal Targeted"]) + BuildPage(pagePersonalTargeted, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"personalTargeted"}, L["Personal Targeted"], "indicators_personal_targeted"), 25, 2) + + + + local growthOptions = { UP= L["Up"], DOWN= L["Down"], LEFT= L["Left"], RIGHT= L["Right"], CENTER_H= L["Center (Horizontal)"], CENTER_V= L["Center (Vertical)"] } + + local function HidePersonalOptions(d) return not d.personalTargetedSpellEnabled end + local function HidePersonalDurationOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellShowDuration end + + local function PersonalTargetedUpdate() + if DF.UpdatePersonalTargetedSpellsPosition then DF:UpdatePersonalTargetedSpellsPosition() end + if DF.UpdateTestPersonalTargetedSpells then DF:UpdateTestPersonalTargetedSpells() end + end + + -- ===== SETTINGS GROUP (Column 1) ===== + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows incoming targeted spells on YOU in the center of your screen."], 250), 30) + settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["To reposition: Unlock frames (/df unlock) and drag the mover."], 250), 30) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Personal Targeted Spells"], db, "personalTargetedSpellEnabled", function() + self:RefreshStates() + if DF.TogglePersonalTargetedSpells then DF:TogglePersonalTargetedSpells(db.personalTargetedSpellEnabled) end + end), 30) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Important Spells Only"], db, "personalTargetedSpellImportantOnly", PersonalTargetedUpdate), 30) + -- Same game CVar as the Targeted List page — Personal detects casts through + -- nameplate tokens too (IsValidCasterUnit), so it has the identical + -- offscreen blind spot. Both checkboxes drive the one CVar. + local ptsOffscreen = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Offscreen Nameplates"], nil, nil, nil, + function() return DF:GetNameplateOffscreen() end, + function(val) DF:SetNameplateOffscreen(val) end), 30) + ptsOffscreen.disableOn = HidePersonalOptions + ptsOffscreen.tooltip = L["Changes the Blizzard game setting 'nameplateShowOffscreen', which decides whether enemies outside your view still get a nameplate. This feature spots casts by watching the game's enemy nameplates, so with the setting off an enemy casting behind you is missed until you turn to face it — even if you have it targeted. Note that this is a game setting, not a DandersFrames one: it applies to your whole account and changes the game's nameplates everywhere."] + Add(settingsGroup, nil, 1) + + -- ===== CONTENT TYPES GROUP (Column 2) ===== + local contentGroup = GUI:CreateSettingsGroup(self.child, 280) + contentGroup:AddWidget(GUI:CreateHeader(self.child, L["Content Types"]), 40) + contentGroup:AddWidget(GUI:CreateLabel(self.child, L["Show in content types:"], 250), 25) + local ptsOpenWorld = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Open World"], db, "personalTargetedSpellInOpenWorld", nil), 25) + ptsOpenWorld.disableOn = HidePersonalOptions + ptsOpenWorld.hideOn = function() return GUI.SelectedMode == "raid" end + local ptsDungeons = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Dungeons"], db, "personalTargetedSpellInDungeons", nil), 25) + ptsDungeons.disableOn = HidePersonalOptions + ptsDungeons.hideOn = function() return GUI.SelectedMode == "raid" end + local ptsRaids = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Raids"], db, "personalTargetedSpellInRaids", nil), 25) + ptsRaids.disableOn = HidePersonalOptions + ptsRaids.hideOn = function() return GUI.SelectedMode == "raid" end + local ptsArena = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Arena"], db, "personalTargetedSpellInArena", nil), 25) + ptsArena.disableOn = HidePersonalOptions + ptsArena.hideOn = function() return GUI.SelectedMode == "raid" end + local ptsBattlegrounds = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Battlegrounds"], db, "personalTargetedSpellInBattlegrounds", nil), 25) + ptsBattlegrounds.disableOn = HidePersonalOptions + ptsBattlegrounds.hideOn = function() return GUI.SelectedMode == "raid" end + contentGroup:AddWidget(GUI:CreateLabel(self.child, L["Content type filters configured in Party tab."], 250), 25) + -- No group-level hideOn: every checkbox in here already carries + -- disableOn = HidePersonalOptions, so the box greys in place like the + -- rest of the page instead of the whole column reflowing when the + -- feature is switched off. (The per-checkbox hideOn for RAID mode + -- stays — that one is about which mode you're in, not an off state.) + Add(contentGroup, nil, 2) + + + + -- Size Group (col1) + local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) + sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Size"]), 40) + local ptsSize = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 20, 80, 1, db, "personalTargetedSpellSize", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsSize.disableOn = HidePersonalOptions + local ptsScale = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "personalTargetedSpellScale", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsScale.disableOn = HidePersonalOptions + local ptsAlpha = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.0, 1.0, 0.05, db, "personalTargetedSpellAlpha", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsAlpha.disableOn = HidePersonalOptions + local ptsSpacing = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], 0, 20, 1, db, "personalTargetedSpellSpacing", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsSpacing.disableOn = HidePersonalOptions + local ptsMaxIcons = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Icons"], 1, 10, 1, db, "personalTargetedSpellMaxIcons", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsMaxIcons.disableOn = HidePersonalOptions + Add(sizeGroup, nil, 1) + + -- Growth Group (col2) + local growthGroup = GUI:CreateSettingsGroup(self.child, 280) + growthGroup:AddWidget(GUI:CreateHeader(self.child, L["Growth"]), 40) + local ptsGrowth = growthGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], growthOptions, db, "personalTargetedSpellGrowth", PersonalTargetedUpdate), 55) + ptsGrowth.disableOn = HidePersonalOptions + Add(growthGroup, nil, 1) + + + + -- Border Group (col1) — Stage 4.4: 3 hand-rolled border widgets + -- (Show / Size / Color) replaced by CreateBorderControls. include + -- set tailored for a "needs attention" alert surface (Personal + -- Targeted = spells targeting you). Skipped: offset (icon has its + -- own positioning), classColor / roleColor (spell alert, not unit + -- identity), colorByTime / colorByType (no aura-state context). + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + GUI:CreateBorderControls(borderGroup, db, "personalTargetedSpell", { + parent = self.child, + include = { alpha = true, inset = true, blendMode = true, + gradient = true, shadow = true, animate = true }, + fullUpdate = PersonalTargetedUpdate, + lightUpdate = PersonalTargetedUpdate, + lightColors = PersonalTargetedUpdate, + refreshStates = function() self:RefreshStates() end, + -- GREY, not hide — every other control on this page greys via + -- disableOn = HidePersonalOptions. + disableWhen = HidePersonalOptions, + sizeMin = 0, sizeMax = 5, sizeStep = 1, -- 0 = animation-only (no solid edge) + }) + Add(borderGroup, nil, 2) + + -- Duration Group (col2) + local durationGroup = GUI:CreateSettingsGroup(self.child, 280) + durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Text"]), 40) + local ptsDuration = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "personalTargetedSpellShowDuration", function() + self:RefreshStates() + PersonalTargetedUpdate() + end), 30) + ptsDuration.disableOn = HidePersonalOptions + -- The cooldown swipe is the radial cooldown sweep on the icon (independent + -- of the numeric duration text), so it's gated only on the feature itself. + local ptsSwipe = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Cooldown Swipe"], db, "personalTargetedSpellShowSwipe", PersonalTargetedUpdate), 30) + ptsSwipe.disableOn = HidePersonalOptions + local ptsDurFont = durationGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "personalTargetedSpellDurationFont", PersonalTargetedUpdate), 55) + ptsDurFont.disableOn = HidePersonalDurationOptions + local ptsDurScale = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.1, db, "personalTargetedSpellDurationScale", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsDurScale.disableOn = HidePersonalDurationOptions + local ptsDurOutline = durationGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "personalTargetedSpellDurationOutline", PersonalTargetedUpdate), 55) + ptsDurOutline.disableOn = HidePersonalDurationOptions + local ptsDurShadow = durationGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "personalTargetedSpellDurationOutline", PersonalTargetedUpdate), 30) + ptsDurShadow.disableOn = HidePersonalDurationOptions + local ptsDurX = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -20, 20, 1, db, "personalTargetedSpellDurationX", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsDurX.disableOn = HidePersonalDurationOptions + local ptsDurY = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -20, 20, 1, db, "personalTargetedSpellDurationY", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsDurY.disableOn = HidePersonalDurationOptions + local ptsDurColor = durationGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "personalTargetedSpellDurationColor", false, PersonalTargetedUpdate), 35) + ptsDurColor.disableOn = HidePersonalDurationOptions + Add(durationGroup, nil, 2) + + + + local function HidePersonalHighlightOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellHighlightImportant end + + local highlightGroup = GUI:CreateSettingsGroup(self.child, 280) + highlightGroup:AddWidget(GUI:CreateHeader(self.child, L["Highlight Settings"]), 40) + local ptsHighlight = highlightGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Highlight Important Spells"], db, "personalTargetedSpellHighlightImportant", function() + self:RefreshStates() + PersonalTargetedUpdate() + end), 30) + ptsHighlight.disableOn = HidePersonalOptions + -- Important Spell Border: the highlight on its own DF.Border (full toolkit), + -- gated by the Highlight Important Spells toggle above. + GUI:CreateBorderControls(highlightGroup, db, "personalTargetedSpellImportant", { + parent = self.child, + noShowToggle = true, -- the Highlight Important Spells checkbox is the gate + include = { alpha = true, inset = true, blendMode = true, + gradient = true, shadow = true, animate = true }, + fullUpdate = PersonalTargetedUpdate, + lightUpdate = PersonalTargetedUpdate, + lightColors = PersonalTargetedUpdate, + refreshStates = function() self:RefreshStates() end, + disableWhen = HidePersonalHighlightOptions, + sizeMin = 0, sizeMax = 8, sizeStep = 1, + }) + Add(highlightGroup, nil, 1) + + + + local function HideInterruptOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellShowInterrupted end + local function HideInterruptXOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellShowInterrupted or not d.personalTargetedSpellInterruptedShowX end + + local interruptGroup = GUI:CreateSettingsGroup(self.child, 280) + interruptGroup:AddWidget(GUI:CreateHeader(self.child, L["Interrupt Settings"]), 40) + local ptsInterrupted = interruptGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Interrupted Visual"], db, "personalTargetedSpellShowInterrupted", function() + self:RefreshStates() + PersonalTargetedUpdate() + end), 30) + ptsInterrupted.disableOn = HidePersonalOptions + local ptsInterruptDur = interruptGroup:AddWidget(GUI:CreateSlider(self.child, L["Duration"], 0.1, 2.0, 0.1, db, "personalTargetedSpellInterruptedDuration", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsInterruptDur.disableOn = HideInterruptOptions + local ptsInterruptTint = interruptGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Tint Color"], db, "personalTargetedSpellInterruptedTintColor", false, PersonalTargetedUpdate), 35) + ptsInterruptTint.disableOn = HideInterruptOptions + local ptsInterruptTintAlpha = interruptGroup:AddWidget(GUI:CreateSlider(self.child, L["Tint Opacity"], 0, 1, 0.1, db, "personalTargetedSpellInterruptedTintAlpha", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsInterruptTintAlpha.disableOn = HideInterruptOptions + Add(interruptGroup, nil, 2) + + local xMarkGroup = GUI:CreateSettingsGroup(self.child, 280) + xMarkGroup:AddWidget(GUI:CreateHeader(self.child, L["X Mark"]), 40) + local ptsShowX = xMarkGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show X Mark"], db, "personalTargetedSpellInterruptedShowX", function() + self:RefreshStates() + PersonalTargetedUpdate() + end), 30) + ptsShowX.disableOn = HideInterruptOptions + local ptsXColor = xMarkGroup:AddWidget(GUI:CreateColorPicker(self.child, L["X Color"], db, "personalTargetedSpellInterruptedXColor", false, PersonalTargetedUpdate), 35) + ptsXColor.disableOn = HideInterruptXOptions + local ptsXSize = xMarkGroup:AddWidget(GUI:CreateSlider(self.child, L["X Size"], 8, 40, 1, db, "personalTargetedSpellInterruptedXSize", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) + ptsXSize.disableOn = HideInterruptXOptions + -- No group-level hideOn: the three controls already grey via their own + -- disableOn, so the box stays put when Show Interrupted Visual is off. + Add(xMarkGroup, nil, 2) + + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + -- DEPRECATED-TARGETED-SPELLS: this used to point at Targeted Spells, + -- which was the page's only link. Repointed rather than removed — + -- Targeted List is the surviving answer to the same question ("what + -- is being cast at my group"), and an empty See Also bar is worse + -- than no bar. + {pageId = "indicators_targetedlist", label = L["Targeted List"]}, + }), 30, "both") + end) + + -- Indicators > Icons + -- + -- ONE level of collapse on this page: the per-icon section header. Each + -- section then holds plain boxes (Settings / Appearance / Position, plus + -- Timer Text on AFK) that are always open. + -- + -- Those boxes used to be collapsible too, each with its own collapseKey. It + -- read as two levels of the same control -- expanding "Leader Icon" got you + -- three more things to expand before you could see a setting -- and made a + -- page of ordinary sliders feel deep. The section header is the only place + -- a collapse earns its keep here, because that IS the choice being made: + -- which icon am I configuring. Everything under it is one screen of rows. + -- + -- Section headers are 280 wide to match the boxes; they were 270, which + -- left the header bar visibly narrower than everything beneath it. + -- + -- Their slot is 36 -- the same as every other collapsible section in the + -- addon (28 of header + 8 of gap). It used to be 28, with the gap supplied + -- by a spacer frame REGISTERED AS A SECTION CHILD, so the gap collapsed + -- along with the section: correct while expanded, but this page defaults + -- every section to collapsed, and 13 headers with no gap between them ran + -- their borders together into one block. The gap belongs to the slot, not + -- to the contents. + local pageIcons = CreateSubTab("indicators", "indicators_icons", L["Icons"]) + DF._SetupGUIPagesPart5(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) +end \ No newline at end of file diff --git a/GUI/Pages/Modules.lua b/GUI/Pages/Modules.lua new file mode 100644 index 00000000..f21005c7 --- /dev/null +++ b/GUI/Pages/Modules.lua @@ -0,0 +1,2130 @@ +-- Part 5 of the settings pages, split from Options.lua. +-- The parts run as a chain so the pages build in their original order. +local addonName, DF = ... +local format = string.format +function DF._SetupGUIPagesPart5(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) + BuildPage(pageIcons, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + -- Every icon on the page needs its own prefix; "combatIcon" was the one + -- omission, so the Combat icon's seven settings were skipped by Copy, Sync + -- and Reset while every other icon on the same page travelled. + Add(CreateCopyButton(self.child, {"roleIcon", "leaderIcon", "raidTargetIcon", "readyCheckIcon", "summonIcon", "resurrectionIcon", "phasedIcon", "afkIcon", "vehicleIcon", "raidRoleIcon", "bgCarrierIcon", "combatIcon", "statusIconFont", "statusIconFontSize", "statusIconFontOutline"}, L["Icons"], "indicators_icons"), 25, 2) + + local anchorOptions = { + CENTER = L["Center"], + TOP = L["Top"], + BOTTOM = L["Bottom"], + LEFT = L["Left"], + RIGHT = L["Right"], + TOPLEFT = L["Top Left"], + TOPRIGHT = L["Top Right"], + BOTTOMLEFT = L["Bottom Left"], + BOTTOMRIGHT = L["Bottom Right"], + } + + local roleStyleOptions = { + BLIZZARD = L["Blizzard"], + CUSTOM = "DF Icons", + EXTERNAL = L["External"], + } + + -- ============================================ + -- ICON TEXT SETTINGS (Collapsible, at top) + -- ============================================ + local textSection = Add(GUI:CreateCollapsibleSection(self.child, L["Icon Text Settings"], false, 280), 36, 1) + + local textLabel = Add(GUI:CreateLabel(self.child, L["Font settings for icons displayed as text (Summon, Res, AFK, etc.)"], 240), 30, 1) + textSection:RegisterChild(textLabel) + + local textFont = Add(GUI:CreateFontDropdown(self.child, L["Font"], db, "statusIconFont", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55, 1) + textSection:RegisterChild(textFont) + + local textSize = Add(GUI:CreateSlider(self.child, L["Font Size"], 8, 24, 1, db, "statusIconFontSize", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55, 1) + textSection:RegisterChild(textSize) + + local textOutline = Add(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "statusIconFontOutline", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55, 1) + textSection:RegisterChild(textOutline) + + local textShadow = Add(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "statusIconFontOutline", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30, 1) + textSection:RegisterChild(textShadow) + + local shadowNote = Add(GUI:CreateLabel(self.child, L["Shadow offset and colour are controlled in General > Global Fonts."], 240), 30, 1) + textSection:RegisterChild(shadowNote) + shadowNote.hideOn = function(d) return not DF:OutlineHasShadow(d.statusIconFontOutline) end + + -- Text Colors header + local colorsLabel = Add(GUI:CreateLabel(self.child, L["Text Colors:"], 240), 25, 1) + textSection:RegisterChild(colorsLabel) + + local summonColor = Add(GUI:CreateColorPicker(self.child, L["Summon"], db, "summonIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(summonColor) + + local resColor = Add(GUI:CreateColorPicker(self.child, L["Resurrection"], db, "resurrectionIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(resColor) + + local afkColor = Add(GUI:CreateColorPicker(self.child, L["AFK"], db, "afkIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(afkColor) + + local phasedColor = Add(GUI:CreateColorPicker(self.child, L["Phased"], db, "phasedIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(phasedColor) + + local vehicleColor = Add(GUI:CreateColorPicker(self.child, L["Vehicle"], db, "vehicleIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(vehicleColor) + + local raidRoleColor = Add(GUI:CreateColorPicker(self.child, L["Raid Role (MT/MA)"], db, "raidRoleIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(raidRoleColor) + + local bgCarrierColor = Add(GUI:CreateColorPicker(self.child, L["BG Carrier"], db, "bgCarrierIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) + textSection:RegisterChild(bgCarrierColor) + + -- ============================================ + -- ROLE ICON (Collapsible) + -- ============================================ + local roleSection = Add(GUI:CreateCollapsibleSection(self.child, L["Role Icon"], false, 280), 36, 1) + + -- Header preview: the Tank/Healer/DPS icons in the currently selected + -- style. Rebuilt live whenever the style, an external path, or a + -- per-role Show toggle changes. Each role's icon desaturates when its + -- Show toggle is off (matching the other icon sections' previews); + -- the whole preview dims only when all three roles are off. + local roleShowKeys = { TANK = "roleIconShowTank", HEALER = "roleIconShowHealer", DAMAGER = "roleIconShowDPS" } + local function UpdateRolePreview() + if not roleSection.SetPreviewIcons then return end + local icons = {} + local anyShown = false + for _, role in ipairs({ "TANK", "HEALER", "DAMAGER" }) do + -- tex may be an atlas name (no coords) or a texture path (+coords). + local tex, l, r, t, b = DF:GetRoleIconTexture(db, role) + if tex then + local shown = db[roleShowKeys[role]] ~= false + anyShown = anyShown or shown + icons[#icons + 1] = { texture = tex, coords = l and { l, r, t, b } or nil, desaturate = not shown } + end + end + roleSection:SetPreviewIcons(icons) + if roleSection.SetPreviewDimmed then roleSection:SetPreviewDimmed(not anyShown) end + end + + -- Settings + local roleSettings = GUI:CreateSettingsGroup(self.child, 280) + roleSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + roleSettings:AddWidget(GUI:CreateDropdown(self.child, L["Icon Style"], roleStyleOptions, db, "roleIconStyle", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 55) + local roleExtTank = roleSettings:AddWidget(GUI:CreateEditBox(self.child, L["Tank Icon Path"], db, "roleIconExternalTank", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end, nil, "Interface\\MyIcons\\Tank.tga"), 55) + roleExtTank.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end + local roleExtHealer = roleSettings:AddWidget(GUI:CreateEditBox(self.child, L["Healer Icon Path"], db, "roleIconExternalHealer", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end, nil, "Interface\\MyIcons\\Healer.tga"), 55) + roleExtHealer.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end + local roleExtDPS = roleSettings:AddWidget(GUI:CreateEditBox(self.child, L["DPS Icon Path"], db, "roleIconExternalDPS", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end, nil, "Interface\\MyIcons\\DPS.tga"), 55) + roleExtDPS.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end + local roleExtNote = roleSettings:AddWidget(GUI:CreateLabel(self.child, L["Paths are relative to your WoW folder and must start with Interface\\. Pasting a full path works — anything before 'Interface' is stripped. Leave empty for DF Icons."], 250), 70) + roleExtNote.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end + -- Per-role filters: which roles ever show an icon (global — apply in and + -- out of combat). The Hide In Combat toggle (Appearance) is an independent gate. + roleSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Tank"], db, "roleIconShowTank", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 30) + roleSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Healer"], db, "roleIconShowHealer", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 30) + roleSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show DPS"], db, "roleIconShowDPS", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 30) + Add(roleSettings, nil, 1) + roleSection:RegisterChild(roleSettings) + + -- Appearance + local roleAppearance = GUI:CreateSettingsGroup(self.child, 280) + roleAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + roleAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "roleIconScale", nil, function() DF:LightweightUpdateIconPosition("role") end, true), 55) + roleAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "roleIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("role") end, true), 55) + roleAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "roleIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("role") end, true)), 55) + roleAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide In Combat"], db, "roleIconHideInCombat", function() DF:UpdateAllRoleIcons() end), 30) + Add(roleAppearance, nil, 1) + roleSection:RegisterChild(roleAppearance) + + -- Position + local rolePosition = GUI:CreateSettingsGroup(self.child, 280) + rolePosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + rolePosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "roleIconAnchor", function() DF:LightweightUpdateIconPosition("role") end), 55) + rolePosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "roleIconX", nil, function() DF:LightweightUpdateIconPosition("role") end, true), 55) + rolePosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "roleIconY", nil, function() DF:LightweightUpdateIconPosition("role") end, true), 55) + Add(rolePosition, nil, 1) + roleSection:RegisterChild(rolePosition) + + -- Initial header preview for the current style. + UpdateRolePreview() + + -- ============================================ + -- STATUS-ICON HEADER PREVIEWS + -- Each status-icon section shows a representative swatch on its header + -- (or the configured status text when "Show as Text" is on), greyed out + -- when the section is disabled. Refreshers are registered globally and + -- re-run by hooked frame-update functions, so previews track live + -- enable/text changes without touching every control's callback. + -- ============================================ + if not DF._iconPreviewHooked then + DF._iconPreviewHooked = true + if DF.UpdateAllFrames then + hooksecurefunc(DF, "UpdateAllFrames", function() DF:RefreshIconPreviews() end) + end + if DF.UpdateAllFramesStatusIcons then + hooksecurefunc(DF, "UpdateAllFramesStatusIcons", function() DF:RefreshIconPreviews() end) + end + end + if DF.iconPreviewRefreshers then wipe(DF.iconPreviewRefreshers) end + + local function WireStatusPreview(section, opts) + local function refresh(force) + if not section.SetPreviewIcons then return end + if not force and not section:IsVisible() then return end + local enabled = (not opts.enableKey) or (db[opts.enableKey] ~= false) + -- Text entries use the icon's configured status-text colour so the + -- preview matches the frame (e.g. IconTextColor). + local colorKey = opts.enableKey and opts.enableKey:gsub("Enabled$", "TextColor") + local textColor = colorKey and db[colorKey] + local entries = {} + if opts.showTextKey and db[opts.showTextKey] then + for _, key in ipairs(opts.texts or {}) do + entries[#entries + 1] = { text = db[key] or key, color = textColor } + end + else + for _, ic in ipairs(opts.icons or {}) do + if type(ic) == "table" then + -- table form: { texture = , coords = {l,r,t,b} } + -- for icons that need a texcoord slice (e.g. raid-target markers + -- off the shared UI-RaidTargetingIcons sheet). SetIconTextureOrAtlas + -- auto-detects atlas vs path, so a plain texture string still works. + entries[#entries + 1] = { texture = ic.texture, coords = ic.coords, inset = ic.inset } + else + entries[#entries + 1] = { texture = ic } + end + end + end + for _, e in ipairs(entries) do e.desaturate = not enabled end + section:SetPreviewIcons(entries) + section:SetPreviewDimmed(not enabled) + end + if DF.iconPreviewRefreshers then table.insert(DF.iconPreviewRefreshers, refresh) end + refresh(true) + end + + -- ============================================ + -- LEADER ICON (Collapsible) + -- ============================================ + local leaderSection = Add(GUI:CreateCollapsibleSection(self.child, L["Leader Icon"], false, 280), 36, 1) + WireStatusPreview(leaderSection, { enableKey = "leaderIconEnabled", icons = { "Interface\\GroupFrame\\UI-Group-LeaderIcon" } }) + + -- Settings + local leaderSettings = GUI:CreateSettingsGroup(self.child, 280) + leaderSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + leaderSettings.disableChildrenOn = function(d) return not d.leaderIconEnabled end + local leaderIconEnableCb = leaderSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Leader Icon"], db, "leaderIconEnabled", function() DF:UpdateAllFrames() end), 30) + leaderIconEnableCb.keepEnabled = true + Add(leaderSettings, nil, 1) + leaderSection:RegisterChild(leaderSettings) + + -- Appearance + local leaderAppearance = GUI:CreateSettingsGroup(self.child, 280) + leaderAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + leaderAppearance.disableChildrenOn = function(d) return not d.leaderIconEnabled end + leaderAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "leaderIconScale", nil, function() DF:LightweightUpdateIconPosition("leader") end, true), 55) + leaderAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "leaderIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("leader") end, true), 55) + leaderAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "leaderIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("leader") end, true)), 55) + leaderAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "leaderIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(leaderAppearance, nil, 1) + leaderSection:RegisterChild(leaderAppearance) + + -- Position + local leaderPosition = GUI:CreateSettingsGroup(self.child, 280) + leaderPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + leaderPosition.disableChildrenOn = function(d) return not d.leaderIconEnabled end + leaderPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "leaderIconAnchor", function() DF:LightweightUpdateIconPosition("leader") end), 55) + leaderPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "leaderIconX", nil, function() DF:LightweightUpdateIconPosition("leader") end, true), 55) + leaderPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "leaderIconY", nil, function() DF:LightweightUpdateIconPosition("leader") end, true), 55) + Add(leaderPosition, nil, 1) + leaderSection:RegisterChild(leaderPosition) + + -- ============================================ + -- RAID TARGET ICON (Collapsible) + -- ============================================ + local raidTargetSection = Add(GUI:CreateCollapsibleSection(self.child, L["Target Marker Icon"], false, 280), 36, 1) + -- Header preview: the four most-used markers (square / cross / triangle / circle), + -- sliced from the classic raid-target sheet via texcoords (the atlas form won't render here). + WireStatusPreview(raidTargetSection, { enableKey = "raidTargetIconEnabled", icons = { + { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.25, 0.5, 0.25, 0.5 }, inset = 2 }, -- square (6) + { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.5, 0.75, 0.25, 0.5 }, inset = 2 }, -- cross (7) + { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.75, 1.0, 0.0, 0.25 }, inset = 2 }, -- triangle (4) + { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.25, 0.5, 0.0, 0.25 }, inset = 2 }, -- circle (2) + } }) + + -- Settings + local rtSettings = GUI:CreateSettingsGroup(self.child, 280) + rtSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + rtSettings.disableChildrenOn = function(d) return not d.raidTargetIconEnabled end + local raidTargetIconEnableCb = rtSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Target Marker Icon"], db, "raidTargetIconEnabled", function() DF:UpdateAllFrames() end), 30) + raidTargetIconEnableCb.keepEnabled = true + Add(rtSettings, nil, 1) + raidTargetSection:RegisterChild(rtSettings) + + -- Appearance + local rtAppearance = GUI:CreateSettingsGroup(self.child, 280) + rtAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + rtAppearance.disableChildrenOn = function(d) return not d.raidTargetIconEnabled end + rtAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "raidTargetIconScale", nil, function() DF:LightweightUpdateIconPosition("raidTarget") end, true), 55) + rtAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "raidTargetIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("raidTarget") end, true), 55) + rtAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "raidTargetIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("raidTarget") end, true)), 55) + rtAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "raidTargetIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(rtAppearance, nil, 1) + raidTargetSection:RegisterChild(rtAppearance) + + -- Position + local rtPosition = GUI:CreateSettingsGroup(self.child, 280) + rtPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + rtPosition.disableChildrenOn = function(d) return not d.raidTargetIconEnabled end + rtPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "raidTargetIconAnchor", function() DF:LightweightUpdateIconPosition("raidTarget") end), 55) + rtPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "raidTargetIconX", nil, function() DF:LightweightUpdateIconPosition("raidTarget") end, true), 55) + rtPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "raidTargetIconY", nil, function() DF:LightweightUpdateIconPosition("raidTarget") end, true), 55) + Add(rtPosition, nil, 1) + raidTargetSection:RegisterChild(rtPosition) + + -- ============================================ + -- READY CHECK ICON (Collapsible) + -- ============================================ + local readySection = Add(GUI:CreateCollapsibleSection(self.child, L["Ready Check Icon"], false, 280), 36, 1) + WireStatusPreview(readySection, { enableKey = "readyCheckIconEnabled", icons = { "UI-LFG-ReadyMark-Raid" } }) + + -- Settings + local rcSettings = GUI:CreateSettingsGroup(self.child, 280) + rcSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + rcSettings.disableChildrenOn = function(d) return not d.readyCheckIconEnabled end + local readyCheckIconEnableCb = rcSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Ready Check Icon"], db, "readyCheckIconEnabled", function() DF:UpdateAllFrames() end), 30) + readyCheckIconEnableCb.keepEnabled = true + rcSettings:AddWidget(GUI:CreateSlider(self.child, L["Persist (seconds)"], 0, 15, 1, db, "readyCheckIconPersist"), 55) + Add(rcSettings, nil, 1) + readySection:RegisterChild(rcSettings) + + -- Appearance + local rcAppearance = GUI:CreateSettingsGroup(self.child, 280) + rcAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + rcAppearance.disableChildrenOn = function(d) return not d.readyCheckIconEnabled end + rcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "readyCheckIconScale", nil, function() DF:LightweightUpdateIconPosition("readyCheck") end, true), 55) + rcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "readyCheckIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("readyCheck") end, true), 55) + rcAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "readyCheckIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("readyCheck") end, true)), 55) + rcAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "readyCheckIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(rcAppearance, nil, 1) + readySection:RegisterChild(rcAppearance) + + -- Position + local rcPosition = GUI:CreateSettingsGroup(self.child, 280) + rcPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + rcPosition.disableChildrenOn = function(d) return not d.readyCheckIconEnabled end + rcPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "readyCheckIconAnchor", function() DF:LightweightUpdateIconPosition("readyCheck") end), 55) + rcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "readyCheckIconX", nil, function() DF:LightweightUpdateIconPosition("readyCheck") end, true), 55) + rcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "readyCheckIconY", nil, function() DF:LightweightUpdateIconPosition("readyCheck") end, true), 55) + Add(rcPosition, nil, 1) + readySection:RegisterChild(rcPosition) + + -- ============================================ + -- SUMMON ICON (Collapsible) + -- ============================================ + local summonSection = Add(GUI:CreateCollapsibleSection(self.child, L["Summon Icon"], false, 280), 36, 1) + WireStatusPreview(summonSection, { enableKey = "summonIconEnabled", showTextKey = "summonIconShowText", icons = { "RaidFrame-Icon-SummonPending" }, texts = { "summonIconTextPending" } }) + + -- Settings + local sumSettings = GUI:CreateSettingsGroup(self.child, 280) + sumSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + sumSettings.disableChildrenOn = function(d) return not d.summonIconEnabled end + local summonIconEnableCb = sumSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Summon Icon"], db, "summonIconEnabled", function() DF:UpdateAllFrames() end), 30) + summonIconEnableCb.keepEnabled = true + sumSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "summonIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + sumSettings:AddWidget(GUI:CreateEditBox(self.child, L["Pending Text"], db, "summonIconTextPending", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + sumSettings:AddWidget(GUI:CreateEditBox(self.child, L["Accepted Text"], db, "summonIconTextAccepted", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + sumSettings:AddWidget(GUI:CreateEditBox(self.child, L["Declined Text"], db, "summonIconTextDeclined", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + Add(sumSettings, nil, 1) + summonSection:RegisterChild(sumSettings) + + -- Appearance + local sumAppearance = GUI:CreateSettingsGroup(self.child, 280) + sumAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + sumAppearance.disableChildrenOn = function(d) return not d.summonIconEnabled end + sumAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "summonIconScale", nil, function() DF:LightweightUpdateIconPosition("summon") end, true), 55) + sumAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "summonIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("summon") end, true), 55) + sumAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "summonIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("summon") end, true)), 55) + sumAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "summonIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(sumAppearance, nil, 1) + summonSection:RegisterChild(sumAppearance) + + -- Position + local sumPosition = GUI:CreateSettingsGroup(self.child, 280) + sumPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + sumPosition.disableChildrenOn = function(d) return not d.summonIconEnabled end + sumPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "summonIconAnchor", function() DF:LightweightUpdateIconPosition("summon") end), 55) + sumPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "summonIconX", nil, function() DF:LightweightUpdateIconPosition("summon") end, true), 55) + sumPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "summonIconY", nil, function() DF:LightweightUpdateIconPosition("summon") end, true), 55) + Add(sumPosition, nil, 1) + summonSection:RegisterChild(sumPosition) + + -- ============================================ + -- BG OBJECTIVE CARRIER ICON (Collapsible) + -- Lights up a unit carrying a battleground objective + -- (flag / orb). Detection is UnitPvpClassification, so it + -- works with Blizzard raid frames fully disabled. + -- ============================================ + local bgCarrierSection = Add(GUI:CreateCollapsibleSection(self.child, L["BG Carrier Icon"], false, 280), 36, 1) + WireStatusPreview(bgCarrierSection, { enableKey = "bgCarrierIconEnabled", showTextKey = "bgCarrierIconShowText", icons = { "Interface\\Icons\\inv_bannerpvp_02" }, texts = { "bgCarrierIconText" } }) + + -- Settings + local bgcSettings = GUI:CreateSettingsGroup(self.child, 280) + bgcSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + bgcSettings.disableChildrenOn = function(d) return not d.bgCarrierIconEnabled end + local bgCarrierIconEnableCb = bgcSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable BG Carrier Icon"], db, "bgCarrierIconEnabled", function() DF:UpdateAllFrames() end), 30) + bgCarrierIconEnableCb.keepEnabled = true + bgcSettings:AddWidget(GUI:CreateLabel(self.child, L["Shows on a friendly party/raid member carrying a battleground objective (flag, orb). Only active inside battlegrounds."], 240), 44) + bgcSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "bgCarrierIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + bgcSettings:AddWidget(GUI:CreateEditBox(self.child, L["Carrier Text"], db, "bgCarrierIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + Add(bgcSettings, nil, 1) + bgCarrierSection:RegisterChild(bgcSettings) + + -- Appearance + local bgcAppearance = GUI:CreateSettingsGroup(self.child, 280) + bgcAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + bgcAppearance.disableChildrenOn = function(d) return not d.bgCarrierIconEnabled end + bgcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "bgCarrierIconScale", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + bgcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "bgCarrierIconAlpha", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + bgcAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "bgCarrierIconFrameLevel", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true)), 55) + Add(bgcAppearance, nil, 1) + bgCarrierSection:RegisterChild(bgcAppearance) + + -- Position + local bgcPosition = GUI:CreateSettingsGroup(self.child, 280) + bgcPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + bgcPosition.disableChildrenOn = function(d) return not d.bgCarrierIconEnabled end + bgcPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "bgCarrierIconAnchor", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55) + bgcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "bgCarrierIconX", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + bgcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "bgCarrierIconY", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + Add(bgcPosition, nil, 1) + bgCarrierSection:RegisterChild(bgcPosition) + + -- ============================================ + -- COMBAT ICON (Collapsible) + -- ============================================ + local combatSection = Add(GUI:CreateCollapsibleSection(self.child, L["Combat Icon"], false, 280), 36, 1) + -- Preview the swords quadrant of the UI-StateIcon sheet (texcoord slice); also + -- greys the section header when the icon is disabled. + WireStatusPreview(combatSection, { enableKey = "combatIconEnabled", icons = { { texture = "Interface\\CharacterFrame\\UI-StateIcon", coords = {0.5, 1.0, 0, 0.49} } } }) + + -- Settings + local combatSettings = GUI:CreateSettingsGroup(self.child, 280) + combatSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + combatSettings.disableChildrenOn = function(d) return not d.combatIconEnabled end + local combatIconEnableCb = combatSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Combat Icon"], db, "combatIconEnabled", function() DF:UpdateAllFrames() end), 30) + combatIconEnableCb.keepEnabled = true + combatSettings:AddWidget(GUI:CreateLabel(self.child, L["Shows crossed swords on a party/raid member who is in combat."], 240), 44) + Add(combatSettings, nil, 1) + combatSection:RegisterChild(combatSettings) + + -- Appearance + local combatAppearance = GUI:CreateSettingsGroup(self.child, 280) + combatAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + combatAppearance.disableChildrenOn = function(d) return not d.combatIconEnabled end + combatAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "combatIconScale", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + combatAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "combatIconAlpha", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + combatAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "combatIconFrameLevel", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true)), 55) + Add(combatAppearance, nil, 1) + combatSection:RegisterChild(combatAppearance) + + -- Position + local combatPosition = GUI:CreateSettingsGroup(self.child, 280) + combatPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + combatPosition.disableChildrenOn = function(d) return not d.combatIconEnabled end + combatPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "combatIconAnchor", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55) + combatPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "combatIconX", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + combatPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "combatIconY", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) + Add(combatPosition, nil, 1) + combatSection:RegisterChild(combatPosition) + + -- ============================================ + -- RESURRECTION ICON (Collapsible) + -- ============================================ + local resSection = Add(GUI:CreateCollapsibleSection(self.child, L["Resurrection Icon"], false, 280), 36, 1) + WireStatusPreview(resSection, { enableKey = "resurrectionIconEnabled", showTextKey = "resurrectionIconShowText", icons = { "RaidFrame-Icon-Rez" }, texts = { "resurrectionIconTextCasting" } }) + + -- Settings + local resSettings = GUI:CreateSettingsGroup(self.child, 280) + resSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + resSettings.disableChildrenOn = function(d) return not d.resurrectionIconEnabled end + local resurrectionIconEnableCb = resSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Resurrection Icon"], db, "resurrectionIconEnabled", function() DF:UpdateAllFrames() end), 30) + resurrectionIconEnableCb.keepEnabled = true + resSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "resurrectionIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + resSettings:AddWidget(GUI:CreateEditBox(self.child, L["Casting Text"], db, "resurrectionIconTextCasting", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + -- ("Pending Text" removed: resurrectionIconTextPending was never read by + -- any render path — live or test — since inception. The pending state + -- renders as the yellow icon tint.) + Add(resSettings, nil, 1) + resSection:RegisterChild(resSettings) + + -- Appearance + local resAppearance = GUI:CreateSettingsGroup(self.child, 280) + resAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + resAppearance.disableChildrenOn = function(d) return not d.resurrectionIconEnabled end + resAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "resurrectionIconScale", nil, function() DF:LightweightUpdateIconPosition("resurrection") end, true), 55) + resAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "resurrectionIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("resurrection") end, true), 55) + resAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "resurrectionIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("resurrection") end, true)), 55) + Add(resAppearance, nil, 1) + resSection:RegisterChild(resAppearance) + + -- Position + local resPosition = GUI:CreateSettingsGroup(self.child, 280) + resPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + resPosition.disableChildrenOn = function(d) return not d.resurrectionIconEnabled end + resPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "resurrectionIconAnchor", function() DF:LightweightUpdateIconPosition("resurrection") end), 55) + resPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "resurrectionIconX", nil, function() DF:LightweightUpdateIconPosition("resurrection") end, true), 55) + resPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "resurrectionIconY", nil, function() DF:LightweightUpdateIconPosition("resurrection") end, true), 55) + Add(resPosition, nil, 1) + resSection:RegisterChild(resPosition) + + -- ============================================ + -- PHASED ICON (Collapsible) + -- ============================================ + local phasedSection = Add(GUI:CreateCollapsibleSection(self.child, L["Phased Icon"], false, 280), 36, 1) + WireStatusPreview(phasedSection, { enableKey = "phasedIconEnabled", showTextKey = "phasedIconShowText", icons = { "RaidFrame-Icon-Phasing" }, texts = { "phasedIconText" } }) + + -- Settings + local phSettings = GUI:CreateSettingsGroup(self.child, 280) + phSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + phSettings.disableChildrenOn = function(d) return not d.phasedIconEnabled end + local phasedIconEnableCb = phSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Phased Icon"], db, "phasedIconEnabled", function() DF:UpdateAllFrames() end), 30) + phasedIconEnableCb.keepEnabled = true + phSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "phasedIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + phSettings:AddWidget(GUI:CreateEditBox(self.child, L["Status Text"], db, "phasedIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + phSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show LFG Eye for Cross-Instance"], db, "phasedIconShowLFGEye", function() DF:UpdateAllFrames() end), 30) + Add(phSettings, nil, 1) + phasedSection:RegisterChild(phSettings) + + -- Appearance + local phAppearance = GUI:CreateSettingsGroup(self.child, 280) + phAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + phAppearance.disableChildrenOn = function(d) return not d.phasedIconEnabled end + phAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "phasedIconScale", nil, function() DF:LightweightUpdateIconPosition("phased") end, true), 55) + phAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "phasedIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("phased") end, true), 55) + phAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "phasedIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("phased") end, true)), 55) + phAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "phasedIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(phAppearance, nil, 1) + phasedSection:RegisterChild(phAppearance) + + -- Position + local phPosition = GUI:CreateSettingsGroup(self.child, 280) + phPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + phPosition.disableChildrenOn = function(d) return not d.phasedIconEnabled end + phPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "phasedIconAnchor", function() DF:LightweightUpdateIconPosition("phased") end), 55) + phPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "phasedIconX", nil, function() DF:LightweightUpdateIconPosition("phased") end, true), 55) + phPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "phasedIconY", nil, function() DF:LightweightUpdateIconPosition("phased") end, true), 55) + Add(phPosition, nil, 1) + phasedSection:RegisterChild(phPosition) + + -- ============================================ + -- AFK ICON (Collapsible) + -- ============================================ + local afkSection = Add(GUI:CreateCollapsibleSection(self.child, L["AFK Icon"], false, 280), 36, 1) + WireStatusPreview(afkSection, { enableKey = "afkIconEnabled", showTextKey = "afkIconShowText", icons = { "characterupdate_clock-icon" }, texts = { "afkIconText" } }) + + -- AFK is the one icon with a fourth box (Timer Text) on top of the + -- standard Settings / Appearance / Position trio. + local afkTimerCB = function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end + + -- Settings + local afkSettings = GUI:CreateSettingsGroup(self.child, 280) + afkSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + afkSettings.disableChildrenOn = function(d) return not d.afkIconEnabled end + local afkIconEnableCb = afkSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable AFK Icon"], db, "afkIconEnabled", function() DF:UpdateAllFrames() end), 30) + afkIconEnableCb.keepEnabled = true + afkSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "afkIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + afkSettings:AddWidget(GUI:CreateEditBox(self.child, L["Status Text"], db, "afkIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + afkSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Timer"], db, "afkIconShowTimer", function() DF:UpdateAllFramesStatusIcons() end), 30) + -- In text mode the timer is merged into the status text, so the Timer + -- Text box is hidden; explain it inherits the main text styling. + local afkTimerInheritNote = afkSettings:AddWidget(GUI:CreateLabel(self.child, L["In Text mode the timer joins the status text and uses its font, colour and position."], 230), 40) + afkTimerInheritNote.hideOn = function(d) return not d.afkIconShowText or not d.afkIconShowTimer end + Add(afkSettings, nil, 1) + afkSection:RegisterChild(afkSettings) + + -- Timer Text — elapsed-time text under the icon. Icon mode only (Show as + -- Text off) with Show Timer on, so the whole box is gated. + local afkTimerGroup = GUI:CreateSettingsGroup(self.child, 280) + afkTimerGroup:AddWidget(GUI:CreateHeader(self.child, L["Timer Text"]), GUI.RowHeight.sectionHeader) + afkTimerGroup.disableChildrenOn = function(d) return not d.afkIconEnabled end + afkTimerGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "afkIconTimerFont", afkTimerCB, "statusIconFont"), 55) + afkTimerGroup:AddWidget(GUI:CreateSlider(self.child, L["Size"], 6, 24, 1, db, "afkIconTimerFontSize", afkTimerCB, afkTimerCB, true), 55) + afkTimerGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "afkIconTimerOutline", afkTimerCB, "statusIconFontOutline"), 55) + afkTimerGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "afkIconTimerColor", false, nil, afkTimerCB, true), 30) + afkTimerGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "afkIconTimerX", afkTimerCB, afkTimerCB, true), 55) + afkTimerGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "afkIconTimerY", afkTimerCB, afkTimerCB, true), 55) + Add(afkTimerGroup, nil, 1) + afkSection:RegisterChild(afkTimerGroup) + afkTimerGroup.hideOn = function(d) return not d.afkIconShowTimer or d.afkIconShowText end + + -- Appearance + local afkAppearance = GUI:CreateSettingsGroup(self.child, 280) + afkAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + afkAppearance.disableChildrenOn = function(d) return not d.afkIconEnabled end + afkAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "afkIconScale", nil, function() DF:LightweightUpdateIconPosition("afk") end, true), 55) + afkAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "afkIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("afk") end, true), 55) + afkAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "afkIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("afk") end, true)), 55) + afkAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "afkIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(afkAppearance, nil, 1) + afkSection:RegisterChild(afkAppearance) + + -- Position + local afkPosition = GUI:CreateSettingsGroup(self.child, 280) + afkPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + afkPosition.disableChildrenOn = function(d) return not d.afkIconEnabled end + afkPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "afkIconAnchor", function() DF:LightweightUpdateIconPosition("afk") end), 55) + afkPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "afkIconX", nil, function() DF:LightweightUpdateIconPosition("afk") end, true), 55) + afkPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "afkIconY", nil, function() DF:LightweightUpdateIconPosition("afk") end, true), 55) + Add(afkPosition, nil, 1) + afkSection:RegisterChild(afkPosition) + + -- ============================================ + -- VEHICLE ICON (Collapsible) + -- ============================================ + local vehSection = Add(GUI:CreateCollapsibleSection(self.child, L["Vehicle Icon"], false, 280), 36, 1) + WireStatusPreview(vehSection, { enableKey = "vehicleIconEnabled", showTextKey = "vehicleIconShowText", icons = { "RaidFrame-Icon-Vehicle" }, texts = { "vehicleIconText" } }) + + -- Settings + local vehSettings = GUI:CreateSettingsGroup(self.child, 280) + vehSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + vehSettings.disableChildrenOn = function(d) return not d.vehicleIconEnabled end + local vehicleIconEnableCb = vehSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Vehicle Icon"], db, "vehicleIconEnabled", function() DF:UpdateAllFrames() end), 30) + vehicleIconEnableCb.keepEnabled = true + vehSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "vehicleIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + vehSettings:AddWidget(GUI:CreateEditBox(self.child, L["Status Text"], db, "vehicleIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + Add(vehSettings, nil, 1) + vehSection:RegisterChild(vehSettings) + + -- Appearance + local vehAppearance = GUI:CreateSettingsGroup(self.child, 280) + vehAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + vehAppearance.disableChildrenOn = function(d) return not d.vehicleIconEnabled end + vehAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "vehicleIconScale", nil, function() DF:LightweightUpdateIconPosition("vehicle") end, true), 55) + vehAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "vehicleIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("vehicle") end, true), 55) + vehAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "vehicleIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("vehicle") end, true)), 55) + vehAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "vehicleIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(vehAppearance, nil, 1) + vehSection:RegisterChild(vehAppearance) + + -- Position + local vehPosition = GUI:CreateSettingsGroup(self.child, 280) + vehPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + vehPosition.disableChildrenOn = function(d) return not d.vehicleIconEnabled end + vehPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "vehicleIconAnchor", function() DF:LightweightUpdateIconPosition("vehicle") end), 55) + vehPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "vehicleIconX", nil, function() DF:LightweightUpdateIconPosition("vehicle") end, true), 55) + vehPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "vehicleIconY", nil, function() DF:LightweightUpdateIconPosition("vehicle") end, true), 55) + Add(vehPosition, nil, 1) + vehSection:RegisterChild(vehPosition) + + -- ============================================ + -- RAID ROLE ICON (Collapsible) + -- ============================================ + local rrSection = Add(GUI:CreateCollapsibleSection(self.child, L["Raid Role Icon (MT/MA)"], false, 280), 36, 1) + WireStatusPreview(rrSection, { enableKey = "raidRoleIconEnabled", showTextKey = "raidRoleIconShowText", icons = { "RaidFrame-Icon-MainTank", "RaidFrame-Icon-MainAssist" }, texts = { "raidRoleIconTextTank", "raidRoleIconTextAssist" } }) + + -- Settings + local rrSettings = GUI:CreateSettingsGroup(self.child, 280) + rrSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) + rrSettings.disableChildrenOn = function(d) return not d.raidRoleIconEnabled end + local raidRoleIconEnableCb = rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Raid Role Icon"], db, "raidRoleIconEnabled", function() DF:UpdateAllFrames() end), 30) + raidRoleIconEnableCb.keepEnabled = true + rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Main Tank"], db, "raidRoleIconShowTank", function() DF:UpdateAllFrames() end), 30) + rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Main Assist"], db, "raidRoleIconShowAssist", function() DF:UpdateAllFrames() end), 30) + rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "raidRoleIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) + rrSettings:AddWidget(GUI:CreateEditBox(self.child, L["Tank Text"], db, "raidRoleIconTextTank", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + rrSettings:AddWidget(GUI:CreateEditBox(self.child, L["Assist Text"], db, "raidRoleIconTextAssist", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) + Add(rrSettings, nil, 1) + rrSection:RegisterChild(rrSettings) + + -- Appearance + local rrAppearance = GUI:CreateSettingsGroup(self.child, 280) + rrAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) + rrAppearance.disableChildrenOn = function(d) return not d.raidRoleIconEnabled end + rrAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "raidRoleIconScale", nil, function() DF:LightweightUpdateIconPosition("raidRole") end, true), 55) + rrAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "raidRoleIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("raidRole") end, true), 55) + rrAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "raidRoleIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("raidRole") end, true)), 55) + rrAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "raidRoleIconHideInCombat", function() DF:UpdateAllFrames() end), 30) + Add(rrAppearance, nil, 1) + rrSection:RegisterChild(rrAppearance) + + -- Position + local rrPosition = GUI:CreateSettingsGroup(self.child, 280) + rrPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) + rrPosition.disableChildrenOn = function(d) return not d.raidRoleIconEnabled end + rrPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "raidRoleIconAnchor", function() DF:LightweightUpdateIconPosition("raidRole") end), 55) + rrPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "raidRoleIconX", nil, function() DF:LightweightUpdateIconPosition("raidRole") end, true), 55) + rrPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "raidRoleIconY", nil, function() DF:LightweightUpdateIconPosition("raidRole") end, true), 55) + Add(rrPosition, nil, 1) + rrSection:RegisterChild(rrPosition) + end) + + -- Indicators > Highlights + local pageHighlights = CreateSubTab("indicators", "indicators_highlights", L["Highlights"]) + BuildPage(pageHighlights, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"selectionHighlight", "hoverHighlight", "aggroHighlight", "aggro"}, L["Highlights"], "indicators_highlights"), 25, 2) + + + local currentSection = nil + + local function AddToSection(widget, height, col) + Add(widget, height, col) + if currentSection then currentSection:RegisterChild(widget) end + return widget + end + + local highlightModes = { + ["NONE"] = L["Hidden"], + ["SOLID"] = L["Solid Border"], + ["ANIMATED"] = L["Animated Border"], + ["DASHED"] = L["Dashed Border"], + ["GLOW"] = L["Glow"], + ["CORNERS"] = L["Corners Only"], + } + + -- All three highlights are the same Thickness / Inset / Alpha trio, so the + -- Inset explanation is written once. Thickness and Alpha get nothing — + -- they say what they are; Inset is the one that reads as jargon, and here + -- the label is a bare "Inset" with not even "Border" in front of it. + local TIP_HL_INSET = L["How far inside the frame edge the highlight sits. Negative values push it outward, so it rings the frame instead of hugging it — useful when the highlight would otherwise sit under auras or text."] + + -- ======================================== + -- SELECTION HIGHLIGHT SECTION + -- ======================================== + local selectionSection = Add(GUI:CreateCollapsibleSection(self.child, L["Selection Highlight"], true), 36, "both") + currentSection = selectionSection + + local function HideSelectionOptions(d) return d.selectionHighlightMode == "NONE" end + + local selGroup = GUI:CreateSettingsGroup(self.child, 280) + selGroup:AddWidget(GUI:CreateHeader(self.child, L["Selection Settings"]), 40) + selGroup:AddWidget(GUI:CreateDropdown(self.child, L["Mode"], highlightModes, db, "selectionHighlightMode", function() + self:RefreshStates() + end), 55) + local selThick = selGroup:AddWidget(GUI:CreateSlider(self.child, L["Thickness"], 1, 10, 1, db, "selectionHighlightThickness", nil, function() DF:LightweightUpdateHighlight("selection") end, true), 55) + selThick.hideOn = HideSelectionOptions + local selInset = selGroup:AddWidget(GUI:CreateSlider(self.child, L["Inset"], -10, 10, 1, db, "selectionHighlightInset", nil, function() DF:LightweightUpdateHighlight("selection") end, true), 55) + selInset.hideOn = HideSelectionOptions + selInset.tooltip = TIP_HL_INSET + local selAlpha = selGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "selectionHighlightAlpha", nil, function() DF:LightweightUpdateHighlight("selection") end, true), 55) + selAlpha.hideOn = HideSelectionOptions + local selCol = selGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "selectionHighlightColor", false, nil, function() DF:LightweightUpdateSelectionHighlightColor() end, true), 35) + selCol.hideOn = HideSelectionOptions + AddToSection(selGroup, nil, 1) + + currentSection = nil + AddSpace(GUI.Space.section, "both") + + -- ======================================== + -- HOVER HIGHLIGHT SECTION + -- ======================================== + local hoverSection = Add(GUI:CreateCollapsibleSection(self.child, L["Hover Highlight"], true), 36, "both") + currentSection = hoverSection + + local function HideHoverOptions(d) return d.hoverHighlightMode == "NONE" end + + local hoverGroup = GUI:CreateSettingsGroup(self.child, 280) + hoverGroup:AddWidget(GUI:CreateHeader(self.child, L["Hover Settings"]), 40) + hoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Mode"], highlightModes, db, "hoverHighlightMode", function() + self:RefreshStates() + end), 55) + local hoverThick = hoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Thickness"], 1, 10, 1, db, "hoverHighlightThickness", nil, function() DF:LightweightUpdateHighlight("hover") end, true), 55) + hoverThick.hideOn = HideHoverOptions + local hoverInset = hoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Inset"], -10, 10, 1, db, "hoverHighlightInset", nil, function() DF:LightweightUpdateHighlight("hover") end, true), 55) + hoverInset.hideOn = HideHoverOptions + hoverInset.tooltip = TIP_HL_INSET + local hoverAlpha = hoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "hoverHighlightAlpha", nil, function() DF:LightweightUpdateHighlight("hover") end, true), 55) + hoverAlpha.hideOn = HideHoverOptions + local hoverCol = hoverGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "hoverHighlightColor", false, nil, function() DF:LightweightUpdateHighlight("hover") end, true), 35) + hoverCol.hideOn = HideHoverOptions + AddToSection(hoverGroup, nil, 1) + + currentSection = nil + AddSpace(GUI.Space.section, "both") + + -- ======================================== + -- AGGRO HIGHLIGHT SECTION + -- ======================================== + local aggroSection = Add(GUI:CreateCollapsibleSection(self.child, L["Aggro Highlight"], true), 36, "both") + currentSection = aggroSection + + local function HideAggroOptions(d) return d.aggroHighlightMode == "NONE" or d.aggroHighlightMode == "HEALTH_COLOR" end + local function HideAggroModeNone(d) return d.aggroHighlightMode == "NONE" end + local function HideCustomColorOptions(d) return d.aggroHighlightMode == "NONE" or not d.aggroUseCustomColors end + local function HideNonTankingColors(d) return d.aggroHighlightMode == "NONE" or not d.aggroUseCustomColors or d.aggroOnlyTanking end + + local aggroModes = { + ["NONE"] = L["Hidden"], + ["HEALTH_COLOR"] = L["Health Bar Color"], + ["SOLID"] = L["Solid Border"], + ["ANIMATED"] = L["Animated Border"], + ["DASHED"] = L["Dashed Border"], + ["GLOW"] = L["Glow"], + ["CORNERS"] = L["Corners Only"], + } + + -- Aggro Settings Group (col1) + local aggroGroup = GUI:CreateSettingsGroup(self.child, 280) + aggroGroup:AddWidget(GUI:CreateHeader(self.child, L["Aggro Settings"]), 40) + aggroGroup:AddWidget(GUI:CreateDropdown(self.child, L["Mode"], aggroModes, db, "aggroHighlightMode", function() + self:RefreshStates() + if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end + end), 55) + local aggroOnlyTanking = aggroGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Only Show When Tanking"], db, "aggroOnlyTanking", function() + self:RefreshStates() + if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end + end), 28) + aggroOnlyTanking.hideOn = HideAggroModeNone + -- These two sound like the same thing and are not: one is about YOUR + -- role, the other about the unit's. Both say which, from their side. + aggroOnlyTanking.tooltip = L["Only highlight threat while YOU are tanking. As a healer or damage dealer the highlight stays off entirely."] + local aggroHideOnTanks = aggroGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide on Tanks"], db, "aggroHideOnTanks", function() + if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end + end), 28) + aggroHideOnTanks.hideOn = HideAggroModeNone + aggroHideOnTanks.tooltip = L["Skip the highlight on tanks in your group — they are supposed to have threat, so lighting them up is noise. Everyone else still shows."] + local aggroThick = aggroGroup:AddWidget(GUI:CreateSlider(self.child, L["Thickness"], 1, 10, 1, db, "aggroHighlightThickness", nil, function() DF:LightweightUpdateHighlight("aggro") end, true), 55) + aggroThick.hideOn = HideAggroOptions + local aggroInset = aggroGroup:AddWidget(GUI:CreateSlider(self.child, L["Inset"], -10, 10, 1, db, "aggroHighlightInset", nil, function() DF:LightweightUpdateHighlight("aggro") end, true), 55) + aggroInset.hideOn = HideAggroOptions + aggroInset.tooltip = TIP_HL_INSET + local aggroAlpha = aggroGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "aggroHighlightAlpha", nil, function() DF:LightweightUpdateHighlight("aggro") end, true), 55) + aggroAlpha.hideOn = HideAggroOptions + AddToSection(aggroGroup, nil, 1) + + -- Threat Colors Group (col2) + local threatGroup = GUI:CreateSettingsGroup(self.child, 280) + threatGroup:AddWidget(GUI:CreateHeader(self.child, L["Threat Colors"]), 40) + local useCustomColors = threatGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Custom Colors"], db, "aggroUseCustomColors", function() + self:RefreshStates() + if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end + end), 28) + useCustomColors.hideOn = HideAggroModeNone + local colorHighThreat = threatGroup:AddWidget(GUI:CreateColorPicker(self.child, L["High Threat (Yellow)"], db, "aggroColorHighThreat", false, nil, function() + DF:LightweightUpdateHighlight("aggro") + end, true), 30) + colorHighThreat.hideOn = HideNonTankingColors + local colorHighestThreat = threatGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Highest Threat (Orange)"], db, "aggroColorHighestThreat", false, nil, function() + DF:LightweightUpdateHighlight("aggro") + end, true), 30) + colorHighestThreat.hideOn = HideNonTankingColors + local colorTanking = threatGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Tanking (Red)"], db, "aggroColorTanking", false, nil, function() + DF:LightweightUpdateHighlight("aggro") + end, true), 30) + colorTanking.hideOn = HideCustomColorOptions + threatGroup:AddWidget(GUI:CreateLabel(self.child, L["Yellow=high, Orange=highest, Red=tanking."], 230), 25) + threatGroup.hideOn = HideAggroModeNone + AddToSection(threatGroup, nil, 2) + + currentSection = nil + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_dispel", label = L["Dispel Overlay"]}, + }), 30, "both") + end) + + -- Auras > Dispel Overlay (moved from Indicators) + local pageDispel = CreateSubTab("auras", "auras_dispel", L["Dispel Overlay"]) + BuildPage(pageDispel, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + Add(CreateCopyButton(self.child, {"dispel"}, L["Dispel Overlay"], "auras_dispel"), 25, 2) + + + local function HideIfDisabled(d) + return d.dispelOverlayEnabled == false + end + -- Alias kept so the widget wiring below reads unchanged — under the + -- unified overlay every appearance control simply follows the toggle. + local HideDispelOptions = HideIfDisabled + + -- 12.1: the container factory owns the overlay unconditionally + -- (FactoryOwnsDispelOverlay == AuraContainer.IsSupported()), so the + -- Display/Icon/Border/Gradient groups are always live here. The legacy + -- "frost while the old path owns it" guards were unreachable and are gone. + + -- Every dispel-page callback funnels through here: the version bump + -- breaks the 12.1 factory drive's fast-path latch, so structural changes + -- (colour source, me/all, icon slots, bleed opt-in) rebuild their slot + -- set and pure styling re-applies. Cheap out of combat; no-op impact + -- pre-12.1 (the legacy path reads settings directly). + local function ApplyDispelSettings() + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + if DF.UpdateAllDispelOverlays then DF:UpdateAllDispelOverlays() end + end + + local function InvalidateCurves() + if DF.InvalidateDispelColorCurve then DF:InvalidateDispelColorCurve() end + ApplyDispelSettings() + end + + local function OnDispelTypeChanged() + InvalidateCurves() + end + + -- ===== ENABLE + SHARED SETTINGS ===== + -- 12.1 unified overlay: ONE container-slot-driven system (Features/ + -- Dispel.lua factory path) covering normal AND private-aura dispels + -- natively. The old Off / DandersFrames / Blizzard / Hybrid source + -- selector collapsed into this single toggle when the Blizzard wrapper + -- retired (settings migrate: any non-Off source = enabled). + local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) + settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) + settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Dispel Overlay"], db, "dispelOverlayEnabled", function() + ApplyDispelSettings() + self:RefreshStates() + GUI:RefreshCurrentPage() + end), 30) + local dispelIndicatorOptions = { [1]= L["Dispellable By Me"], [2]= L["All Dispellable"] } + local dispelIndicatorDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Show Overlay For"], dispelIndicatorOptions, db, "dispelOverlayDispelType", function() + OnDispelTypeChanged() + end), 55) + dispelIndicatorDropdown.hideOn = HideIfDisabled + dispelIndicatorDropdown.tooltip = L["Dispellable By Me only lights up debuffs your current spec can actually remove. All Dispellable lights up every removable debuff, including ones for someone else to handle."] + -- Dispel-type colours come from the shared account palette on the Colors page + -- (defaults = the game palette; Reset restores it). The overlay always follows + -- it — no game-vs-custom toggle — so this is just a link to where you edit them. + local overlayColorsLink = GUI:CreateDispelColorsPageLink(self.child, 260) + settingsGroup:AddWidget(overlayColorsLink, (overlayColorsLink.layoutHeight or 16) + 2) + overlayColorsLink.hideOn = HideIfDisabled + Add(settingsGroup, nil, 1) + + -- The four boxes below used to sit under an "Appearance" collapsible + -- header -- the last section in the addon named for a CATEGORY rather + -- than for a thing. A header means "here is another one of these", + -- which is why Icons and Highlights keep theirs and this one goes. + -- + -- It costs nothing to remove: every box already declares the same + -- hideOn it was inheriting from the section, so the whole block still + -- disappears when the overlay is off. + + -- Display group (quick toggles) — Column 1 + local displayGroup = GUI:CreateSettingsGroup(self.child, 280) + displayGroup:AddWidget(GUI:CreateHeader(self.child, L["Display"]), 40) + -- Show Border / Show Gradient are the master toggles for their features, so + -- each one now HEADS its own group below (Border / Gradient) — mirroring the + -- Show Dispel Icon toggle that heads the Icon group. Keeps every group's + -- on/off switch at the top of that group. + -- Boolean toggles GREY their dependent controls in place (addon-wide + -- convention); hideOn stays for the feature/variant switches only. + local DisableIfNoGradient = function(d) return d.dispelShowGradient == false end + local DisableIfNoBorder = function(d) return d.dispelShowBorder == false end + local DisableIfNoIcon = function(d) return d.dispelShowIcon == false end + local animate = displayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Pulse Overlay"], db, "dispelAnimate", function() + ApplyDispelSettings() + end), 30) + animate.hideOn = HideDispelOptions + -- (Color Name Text removed 2026-07-25 — see Features/Dispel.lua. Its only render + -- path was the legacy test-mode show, so it tinted the preview and did nothing + -- live; a real version needs an occlusion-safe name tint on the slot overlay.) + displayGroup.hideOn = HideDispelOptions + Add(displayGroup, nil, 1) + + -- ===== ICON GROUP (Column 2) ===== + local iconGroup = GUI:CreateSettingsGroup(self.child, 280) + iconGroup:AddWidget(GUI:CreateHeader(self.child, L["Dispel Type Icon"]), 40) + local showIcon = iconGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Dispel Icon"], db, "dispelShowIcon", function() + ApplyDispelSettings() + self:RefreshStates() + end), 30) + showIcon.hideOn = HideDispelOptions + local iconSize = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 10, 40, 1, db, "dispelIconSize", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + iconSize.hideOn = HideDispelOptions + iconSize.disableOn = DisableIfNoIcon + local iconAlpha = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Opacity"], 0.1, 1.0, 0.1, db, "dispelIconAlpha", function() + InvalidateCurves() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + iconAlpha.hideOn = HideDispelOptions + iconAlpha.disableOn = DisableIfNoIcon + local iconPositions = { + ["CENTER"]= L["Center"], ["TOP"]= L["Top"], ["BOTTOM"]= L["Bottom"], + ["LEFT"]= L["Left"], ["RIGHT"]= L["Right"], + ["TOPLEFT"]= L["Top Left"], ["TOPRIGHT"]= L["Top Right"], + ["BOTTOMLEFT"]= L["Bottom Left"], ["BOTTOMRIGHT"]= L["Bottom Right"], + } + local iconPos = iconGroup:AddWidget(GUI:CreateDropdown(self.child, L["Icon Position"], iconPositions, db, "dispelIconPosition", function() + ApplyDispelSettings() + end), 55) + iconPos.hideOn = HideDispelOptions + iconPos.disableOn = DisableIfNoIcon + local iconOffsetX = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "dispelIconOffsetX", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + iconOffsetX.hideOn = HideDispelOptions + iconOffsetX.disableOn = DisableIfNoIcon + local iconOffsetY = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "dispelIconOffsetY", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + iconOffsetY.hideOn = HideDispelOptions + iconOffsetY.disableOn = DisableIfNoIcon + iconGroup.hideOn = HideDispelOptions + Add(iconGroup, nil, 2) + + -- ===== BORDER GROUP (Column 1) ===== + local borderGroup = GUI:CreateSettingsGroup(self.child, 280) + borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + local showBorder = borderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Border"], db, "dispelShowBorder", function() + ApplyDispelSettings() + self:RefreshStates() + end), 30) + showBorder.hideOn = HideDispelOptions + local borderSize = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Thickness"], 1, 6, 1, db, "dispelBorderSize", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + borderSize.hideOn = HideDispelOptions + borderSize.disableOn = DisableIfNoBorder + local borderInset = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Inset"], -4, 4, 1, db, "dispelBorderInset", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + borderInset.hideOn = HideDispelOptions + borderInset.disableOn = DisableIfNoBorder + borderInset.tooltip = L["How far inside the frame edge the dispel border sits. Negative values push it outward, ringing the frame rather than hugging it."] + local borderAlpha = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Opacity"], 0.1, 1.0, 0.1, db, "dispelBorderAlpha", function() + InvalidateCurves() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + borderAlpha.hideOn = HideDispelOptions + borderAlpha.disableOn = DisableIfNoBorder + borderGroup.hideOn = HideDispelOptions -- works in BOTH modes (game = ring slot) + Add(borderGroup, nil, 2) + + -- ===== GRADIENT GROUP (Column 1) ===== + -- Column 1 with Display, not column 2 with Border: this is the OVERLAY's + -- own gradient (Full Frame / Top Edge / Edge Glow), so it belongs with + -- the overlay's display mode rather than with the border drawn over it. + local gradientGroup = GUI:CreateSettingsGroup(self.child, 280) + gradientGroup:AddWidget(GUI:CreateHeader(self.child, L["Gradient"]), 40) + local showGradient = gradientGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Gradient"], db, "dispelShowGradient", function() + ApplyDispelSettings() + self:RefreshStates() + end), 30) + showGradient.hideOn = HideDispelOptions + local gradientStyles = { + ["FULL"]= L["Full Frame"], ["TOP"]= L["Top Edge"], ["BOTTOM"]= L["Bottom Edge"], + ["LEFT"]= L["Left Edge"], ["RIGHT"]= L["Right Edge"], ["EDGE"]= L["Edge Glow (All Sides)"], + } + local gradStyle = gradientGroup:AddWidget(GUI:CreateDropdown(self.child, L["Gradient Position"], gradientStyles, db, "dispelGradientStyle", function() + self:RefreshStates() + ApplyDispelSettings() + end), 55) + gradStyle.hideOn = HideDispelOptions + gradStyle.disableOn = DisableIfNoGradient + gradStyle.tooltip = L["Where the coloured wash sits on the frame. Full covers the whole bar; the edge options leave the middle clear so you can still read health and text underneath."] + local onHealthCheck = gradientGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show On Current Health Only"], db, "dispelGradientOnCurrentHealth", function() + ApplyDispelSettings() + end), 30) + onHealthCheck.hideOn = function(d) return HideIfDisabled(d) or d.dispelGradientStyle ~= "FULL" end + onHealthCheck.disableOn = DisableIfNoGradient + onHealthCheck.tooltip = L["Keeps the wash inside the filled part of the health bar, so it shrinks as the unit takes damage instead of covering the empty section too."] + local gradSize = gradientGroup:AddWidget(GUI:CreateSlider(self.child, L["Gradient Size"], 0.1, 1.0, 0.1, db, "dispelGradientSize", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + gradSize.hideOn = HideDispelOptions + gradSize.disableOn = DisableIfNoGradient + local gradAlpha = gradientGroup:AddWidget(GUI:CreateSlider(self.child, L["Gradient Opacity"], 0.1, 1.0, 0.1, db, "dispelGradientAlpha", function() + InvalidateCurves() + end, function() DF:InvalidateDispelColorCurve(); DF:LightweightUpdateDispelOverlay() end, true), 55) + gradAlpha.hideOn = HideDispelOptions + gradAlpha.disableOn = DisableIfNoGradient + local blendModes = { ["ADD"]= L["Glow (ADD)"], ["BLEND"]= L["Solid (BLEND)"] } + local blendDropdown = gradientGroup:AddWidget(GUI:CreateDropdown(self.child, L["Blend Mode"], blendModes, db, "dispelGradientBlendMode", function() + ApplyDispelSettings() + end), 55) + blendDropdown.hideOn = HideDispelOptions + blendDropdown.disableOn = DisableIfNoGradient + -- Darken effect lives at the bottom of the Gradient group (it only + -- renders behind the gradient). + local darkenCheck = gradientGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Darken Behind Gradient"], db, "dispelGradientDarkenEnabled", function() + self:RefreshStates() + ApplyDispelSettings() + end), 30) + darkenCheck.hideOn = HideDispelOptions + darkenCheck.disableOn = DisableIfNoGradient + darkenCheck.tooltip = L["Dims the frame underneath the wash so the dispel colour reads cleanly over a bright class colour or a busy health bar."] + local darkenAlpha = gradientGroup:AddWidget(GUI:CreateSlider(self.child, L["Darken Amount"], 0.1, 1.0, 0.05, db, "dispelGradientDarkenAlpha", function() + ApplyDispelSettings() + end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) + -- HIDE when the dispel feature is off (variant); GREY when the boolean + -- toggles it depends on are off (disabled-in-place). + darkenAlpha.hideOn = HideIfDisabled + darkenAlpha.disableOn = function(d) + return d.dispelShowGradient == false or not d.dispelGradientDarkenEnabled + end + gradientGroup.hideOn = HideDispelOptions + Add(gradientGroup, nil, 1) + + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_debuffs", label = L["Debuffs"]}, + {pageId = "indicators_highlights", label = L["Highlights"]}, + }), 30, "both") + end) + + -- ======================================== + -- CATEGORY: Profiles + -- ======================================== + CreateCategory("profiles", L["Profiles"]) + + -- ======================================== + -- Profiles > Auto Layouts (Raid only) + -- ======================================== + local pageAutoProfiles = CreateSubTab("profiles", "profiles_auto", L["Auto Layouts"]) + BuildPage(pageAutoProfiles, function(self, db, Add, AddSpace) + if DF.AutoProfilesUI and DF.AutoProfilesUI.BuildPage then + DF.AutoProfilesUI:BuildPage(GUI, self, db, Add, AddSpace) + else + Add(GUI:CreateHeader(self.child, L["Auto Layouts"]), 40, "both") + Add(GUI:CreateLabel(self.child, L["Auto Layouts module not loaded."], 400), 30, "both") + end + end) + + -- Profiles > Manage + local pageManage = CreateSubTab("profiles", "profiles_manage", L["Manage"]) + BuildPage(pageManage, function(self, db, Add, AddSpace, AddSyncPoint) + local currentProfile = DF:GetCurrentProfile() + local profiles = DF:GetProfiles() + + -- Helper to add to current section (for collapsible sections this pattern won't apply, but we use groups) + local currentSection = nil + local function AddToSection(widget, col, colNum) + widget.layoutCol = colNum or col + table.insert(self.children, widget) + end + + -- ============================================ + -- COLUMN 1: Profile List & Creation + -- ============================================ + + -- Current Profile Info Group + local currentGroup = GUI:CreateSettingsGroup(self.child, 280) + currentGroup:AddWidget(GUI:CreateHeader(self.child, L["Current Profile"]), 40) + currentGroup:AddWidget(GUI:CreateLabel(self.child, "|cff00ff00" .. currentProfile .. "|r", 240), 25) + AddToSection(currentGroup, nil, 1) + + -- Available Profiles Group + local listGroup = GUI:CreateSettingsGroup(self.child, 280) + listGroup:AddWidget(GUI:CreateHeader(self.child, L["Available Profiles"]), 40) + + -- Create a container frame for profile list with fixed width and max height + local maxListHeight = 180 + local contentHeight = #profiles * 28 + 10 + local listHeight = math.min(contentHeight, maxListHeight) + local listContainer = CreateFrame("Frame", nil, self.child, "BackdropTemplate") + listContainer:SetSize(240, listHeight) + GUI:CreateElementBackdrop(listContainer, { bgColor = {0, 0, 0, 0.3}, borderColor = {0.3, 0.3, 0.3, 1} }) + listGroup:AddWidget(listContainer, listHeight + 5) + + -- Create scroll frame for the profile list + local profileScroll = CreateFrame("ScrollFrame", nil, listContainer, "ScrollFrameTemplate") + profileScroll:SetPoint("TOPLEFT", 2, -2) + profileScroll:SetPoint("BOTTOMRIGHT", -22, 2) + + GUI.StyleScrollBar(profileScroll) + if contentHeight <= maxListHeight and profileScroll.ScrollBar then + profileScroll.ScrollBar:Hide() + profileScroll:SetPoint("BOTTOMRIGHT", -4, 2) + end + + -- Create scroll child to hold profile buttons + local profileScrollChild = CreateFrame("Frame", nil, profileScroll) + profileScrollChild:SetSize(210, contentHeight) + profileScroll:SetScrollChild(profileScrollChild) + + -- Profile buttons inside scroll child + local py = -3 + for i, p in ipairs(profiles) do + -- Standard theme hover: picking a profile IS the action of this page, + -- so the row gets the same accent wash as any other button rather than + -- the neutral "this is a place" grey. The "this is the active profile" + -- cue is SetActive's accent fill + border, which stays visible under + -- the hover (applyHoverState keeps the active border). + local btn = CreateFrame("Button", nil, profileScrollChild, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 2, py) + DF.GUI:StyleButton(btn, { + width = 206, height = 24, + text = p, font = "DFFontHighlightSmall", + }) + btn:SetActive(p == currentProfile) + btn:SetScript("OnClick", function() + DF:SetProfile(p) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end) + py = py - 28 + end + + AddToSection(listGroup, nil, 1) + + -- Create New Profile Group + local createGroup = GUI:CreateSettingsGroup(self.child, 280) + createGroup:AddWidget(GUI:CreateHeader(self.child, L["Create New Profile"]), 40) + + local input = GUI:CreateInput(self.child, L["Profile Name"], 240) + createGroup:AddWidget(input, 50) + + -- Button row for create actions + local btnRow = CreateFrame("Frame", nil, self.child) + btnRow:SetSize(240, 28) + + local createBtn = GUI:CreateButton(self.child, L["Create Empty"], 115, 24, function() + local text = input.EditBox:GetText() + if not text or text == "" then + DF:Err("Please enter a profile name.") + return + end + DF:SetProfile(text) + input.EditBox:SetText("") + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end) + createBtn:SetParent(btnRow) + createBtn:SetPoint("LEFT", 0, 0) + + local dupeBtn = GUI:CreateButton(self.child, L["Duplicate Current"], 115, 24, function() + local text = input.EditBox:GetText() + if not text or text == "" then + DF:Err("Please enter a name for the duplicated profile.") + return + end + if DF:DuplicateProfile(text) then + input.EditBox:SetText("") + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end + end) + dupeBtn:SetParent(btnRow) + dupeBtn:SetPoint("LEFT", createBtn, "RIGHT", 10, 0) + + createGroup:AddWidget(btnRow, 32) + AddToSection(createGroup, nil, 1) + + -- ============================================ + -- COLUMN 2: Actions & Settings + -- ============================================ + + -- Profile Actions Group + local actionsGroup = GUI:CreateSettingsGroup(self.child, 280) + actionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Profile Actions"]), 40) + + actionsGroup:AddWidget(GUI:CreateIconButton(self.child, "delete", L["Delete Current Profile"], 240, 26, function() + local p = DF:GetCurrentProfile() + if p == "Default" then + DF:Err("Cannot delete Default profile.") + return + end + -- The profile name rides the closure rather than the StaticPopup + -- `data` field it used to be poked onto after the fact. + DF:ShowPopupAlert({ + title = L["Delete Profile"], + message = format(L["Delete profile '%s'?\n\nThis cannot be undone."], p), + buttons = { + { + label = L["Delete"], + onClick = function() + DF:SetProfile("Default") + DF:DeleteProfile(p) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end, nil, "left"), 32) + + actionsGroup:AddWidget(GUI:CreateIconButton(self.child, "refresh", L["Reset Profile to Defaults"], 240, 26, function() + DF:ShowPopupAlert({ + title = L["Reset Profile to Defaults"], + message = L["Reset current profile to defaults?\nThis will reset BOTH Party and Raid settings."], + buttons = { + { + label = L["Reset"], + onClick = function() + DF:ResetFullProfile() + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end, nil, "left"), 32) + + AddToSection(actionsGroup, nil, 2) + + -- Copy Settings Group + local copyGroup = GUI:CreateSettingsGroup(self.child, 280) + copyGroup:AddWidget(GUI:CreateHeader(self.child, L["Copy Settings"]), 40) + copyGroup:AddWidget(GUI:CreateLabel(self.child, L["Copy all settings between Party and Raid modes."], 240), 25) + + -- Both directions are the same confirm with the modes swapped. + local function ConfirmCopyProfile(src, dest, message) + DF:ShowPopupAlert({ + title = L["Copy Settings"], + message = message, + buttons = { + { + label = L["Copy"], + onClick = function() + DF:CopyProfile(src, dest) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end + + copyGroup:AddWidget(GUI:CreateIconButton(self.child, "chevron_right", L["Party to Raid"], 240, 26, function() + ConfirmCopyProfile("party", "raid", + L["Copy Party settings to Raid?\n\nThis will overwrite all Raid settings with your current Party settings."]) + end, nil, "left"), 32) + + copyGroup:AddWidget(GUI:CreateIconButton(self.child, "chevron_right", L["Raid to Party"], 240, 26, function() + ConfirmCopyProfile("raid", "party", + L["Copy Raid settings to Party?\n\nThis will overwrite all Party settings with your current Raid settings."]) + end, nil, "left"), 32) + + AddToSection(copyGroup, nil, 2) + + -- Auto-Switch by Spec Group + local specGroup = GUI:CreateSettingsGroup(self.child, 280) + specGroup:AddWidget(GUI:CreateHeader(self.child, L["Auto-Switch by Spec"]), 40) + + -- Initialize per-character data if needed + if not DandersFramesCharDB then + DandersFramesCharDB = { enableSpecSwitch = false, specProfiles = {} } + end + + local specEnableCb = specGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Spec Auto-Switch"], DandersFramesCharDB, "enableSpecSwitch"), 30) + specEnableCb.keepEnabled = true + -- The enable flag lives on the per-character DB (not the page db arg), so + -- the grey predicate reads DandersFramesCharDB directly. + specGroup.disableChildrenOn = function() return not (DandersFramesCharDB and DandersFramesCharDB.enableSpecSwitch) end + + local numSpecs = GetNumSpecializations and GetNumSpecializations() or 0 + if numSpecs > 0 then + -- Build profile list for dropdown + local pList = { [""]= L["None"] } + for _, p in ipairs(profiles) do + pList[p] = p + end + + if not DandersFramesCharDB.specProfiles then + DandersFramesCharDB.specProfiles = {} + end + + for i = 1, numSpecs do + local _, name = GetSpecializationInfo(i) + if name then + local specIdx = i -- capture for the get/set closures + -- Custom get/set: an unset spec reads back as "" so it displays + -- the "None" option instead of the raw nil ("nil") value. None is + -- stored as nil to keep the DB tidy; CheckProfileAutoSwitch treats + -- both nil and "" as "don't switch". + specGroup:AddWidget(GUI:CreateDropdown(self.child, name, pList, nil, nil, nil, + function() return DandersFramesCharDB.specProfiles[specIdx] or "" end, + function(v) DandersFramesCharDB.specProfiles[specIdx] = (v ~= "" and v) or nil end), 55) + end + end + else + specGroup:AddWidget(GUI:CreateLabel(self.child, L["Specialization data not available."], 240), 25) + end + + AddToSection(specGroup, nil, 2) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "profiles_importexport", label = L["Import/Export"]}, + }), 30, "both") + end) + + -- Profiles > Import/Export + local pageImportExport = CreateSubTab("profiles", "profiles_importexport", L["Import/Export"]) + BuildPage(pageImportExport, function(self, db, Add, AddSpace, AddSyncPoint) + -- Store references + self.exportCheckboxes = {} + self.importCheckboxes = {} + self.exportFrameTypes = {party = true, raid = true} + self.importFrameTypes = {party = true, raid = true} + + -- Derived from the category registry (single source of truth) so this list + -- can never drift from DF.ExportCategories when categories change. + local categoryOrder = {} + for cat in pairs(DF.ExportCategoryInfo) do table.insert(categoryOrder, cat) end + table.sort(categoryOrder, function(a, b) + return (DF.ExportCategoryInfo[a].order or 99) < (DF.ExportCategoryInfo[b].order or 99) + end) + + -- Page-scope note: unlike the rest of the settings window, this page is + -- NOT scoped by the party/raid tab -- exports and imports operate on the + -- whole profile, gated only by the Export for / Import for rows. + local scopeBanner = GUI:CreateInfoBanner(self.child, { + tone = "info", + text = L["Profiles include both Party and Raid settings. Exporting and importing always works on the profile as a whole, no matter which mode tab is selected above. Use the 'Export for' and 'Import for' checkboxes in each column to choose which mode's settings are included."], + }) + Add(scopeBanner, scopeBanner.layoutHeight or 44, "both") + + -- Helper to add to section + local function AddToSection(widget, col, colNum) + widget.layoutCol = colNum or col + table.insert(self.children, widget) + end + + -- Helper to create themed small checkbox + local function CreateSmallCheckbox(parent, label, initialChecked) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(100, 18) + + local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") + cb:SetPoint("LEFT", 0, 0) + GUI:StyleCheckButton(cb, { size = 14, checkSize = 8, themeRoot = parent }) + + local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", cb, "RIGHT", 4, 0) + txt:SetText(label) + txt:SetTextColor(0.85, 0.85, 0.85) + cb.label = txt + + cb:SetChecked(initialChecked or false) + + container.checkbox = cb + container.SetChecked = function(self, val) cb:SetChecked(val) end + container.GetChecked = function(self) return cb:GetChecked() end + container.Enable = function(self) cb:Enable(); container:SetAlpha(1) end + container.Disable = function(self) cb:Disable(); container:SetAlpha(0.35) end + + return container + end + + -- Helper to create small themed button + local function CreateSmallButton(parent, text, width) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { width = width, height = 20, text = text }) + btn.text = btn.Text + return btn + end + + -- ======================================== + -- COLUMN 1: EXPORT + -- ======================================== + + -- "What to Export" group: picks the profile, the mode and the categories. + -- Named for the question it answers -- "Export Settings" read as both + -- "settings for exporting" and "export your settings". + local exportSettingsGroup = GUI:CreateSettingsGroup(self.child, 280) + exportSettingsGroup:AddWidget(GUI:CreateHeader(self.child, L["What to Export"]), 40) + + -- Profile name input + local nameInput = GUI:CreateInput(self.child, L["Profile Name"], 240) + local currentProfileName = (DF.db and DF.db.keys and DF.db.keys.profile) or "My Profile" + nameInput.EditBox:SetText(currentProfileName) + self.exportNameEdit = nameInput.EditBox + exportSettingsGroup:AddWidget(nameInput, 50) + + -- Preset buttons row + local presetRow = CreateFrame("Frame", nil, self.child) + presetRow:SetSize(240, 24) + + -- frameTypes: true = All checks Party+Raid, false = None clears them, + -- nil = Look/Layout leave the frame-type row alone. + local presets = { + {name = "All", x = 0, frameTypes = true, cats = categoryOrder}, + {name = "Look", x = 60, cats = {"bars", "auras", "dispel", "missingBuffs", "defensives", "targetedSpells", "targetedList", "text", "textDesigner", "icons", "other"}}, + {name = "Layout", x = 120, cats = {"position", "layout"}}, + {name = "None", x = 180, frameTypes = false, cats = {}}, + } + + for _, p in ipairs(presets) do + local btn = CreateSmallButton(presetRow, L[p.name], 56) + btn:SetPoint("LEFT", p.x, 0) + btn:SetScript("OnClick", function() + local sel = {} + for _, c in ipairs(p.cats) do sel[c] = true end + for cat, cb in pairs(self.exportCheckboxes) do cb:SetChecked(sel[cat] or false) end + -- All/None also drive the Party/Raid row -- keep the STATE table in + -- sync (SetChecked does not fire the checkbox OnClick handlers). + if p.frameTypes ~= nil and self.exportFrameTypeBoxes then + for ft, box in pairs(self.exportFrameTypeBoxes) do + box:SetChecked(p.frameTypes) + self.exportFrameTypes[ft] = p.frameTypes + end + if self.UpdateExportCategoryState then self.UpdateExportCategoryState() end + end + end) + end + exportSettingsGroup:AddWidget(presetRow, 28) + + -- Frame types row ("Export for" -- the modes whose settings ship; the + -- category list below picks WHICH settings, this row picks WHOSE) + exportSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Export for"], 240), 22) + local ftRow = CreateFrame("Frame", nil, self.child) + ftRow:SetSize(240, 20) + + local partyExp = CreateSmallCheckbox(ftRow, L["Party"], true) + partyExp:SetPoint("LEFT", 0, 0) + partyExp.checkbox:SetScript("OnClick", function(s) + self.exportFrameTypes.party = s:GetChecked() + if self.UpdateExportCategoryState then self.UpdateExportCategoryState() end + end) + + local raidExp = CreateSmallCheckbox(ftRow, L["Raid"], true) + raidExp:SetPoint("LEFT", 80, 0) + raidExp.checkbox:SetScript("OnClick", function(s) + self.exportFrameTypes.raid = s:GetChecked() + if self.UpdateExportCategoryState then self.UpdateExportCategoryState() end + end) + self.exportFrameTypeBoxes = {party = partyExp, raid = raidExp} + exportSettingsGroup:AddWidget(ftRow, 24) + + -- Categories ("Settings to include" -- sub-settings of the modes above) + exportSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Settings to include"], 240), 22) + for _, cat in ipairs(categoryOrder) do + local info = DF.ExportCategoryInfo[cat] + local catRow = CreateFrame("Frame", nil, self.child) + catRow:SetSize(240, 18) + + local cb = CreateSmallCheckbox(catRow, L[info.name], true) + cb:SetPoint("LEFT", 0, 0) + self.exportCheckboxes[cat] = cb + exportSettingsGroup:AddWidget(catRow, 20) + end + + -- Grey the category list while no mode is selected (nothing would + -- export) -- the addon-wide disabled-means-dimmed convention. + self.UpdateExportCategoryState = function() + local enabled = self.exportFrameTypes.party or self.exportFrameTypes.raid + for _, cb in pairs(self.exportCheckboxes) do + if enabled then cb:Enable() else cb:Disable() end + end + end + + AddToSection(exportSettingsGroup, nil, 1) + + -- Export Actions Group + local exportActionsGroup = GUI:CreateSettingsGroup(self.child, 280) + exportActionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Export"]), 40) + + -- Export button + exportActionsGroup:AddWidget(GUI:CreateIconButton(self.child, "upload", L["Generate Export String"], 240, 26, function() + local selectedCats = {} + local allSelected = true + for _, cat in ipairs(categoryOrder) do + if self.exportCheckboxes[cat]:GetChecked() then + table.insert(selectedCats, cat) + else + allSelected = false + end + end + if allSelected then selectedCats = nil end + + local profileName = self.exportNameEdit:GetText() + if profileName == "" then profileName = nil end + + local str = DF:ExportProfile(selectedCats, self.exportFrameTypes, profileName) + if str and self.exportEditBox then + self.exportEditBox:SetText(str) + self.exportEditBox:HighlightText() + self.exportEditBox:SetFocus() + DF:Say("Export generated.") + elseif not str then + DF:Err("Export failed - no string returned") + end + end), 32) + + -- Export text area + local exportScrollContainer = GUI:CreateTextArea(self.child, { width = 240, height = 100 }) + self.exportEditBox = exportScrollContainer.EditBox + + exportActionsGroup:AddWidget(exportScrollContainer, 105) + + -- Select All button + exportActionsGroup:AddWidget(GUI:CreateButton(self.child, L["Select All Text"], 240, 24, function() + if self.exportEditBox then + self.exportEditBox:HighlightText() + self.exportEditBox:SetFocus() + end + end), 28) + + AddToSection(exportActionsGroup, nil, 1) + + -- ======================================== + -- COLUMN 2: IMPORT + -- ======================================== + + -- Import String Group + local importStringGroup = GUI:CreateSettingsGroup(self.child, 280) + importStringGroup:AddWidget(GUI:CreateHeader(self.child, L["Import String"]), 40) + + -- Import text area + local importScrollContainer = GUI:CreateTextArea(self.child, { width = 240, height = 80 }) + self.importEditBox = importScrollContainer.EditBox + + importStringGroup:AddWidget(importScrollContainer, 85) + + -- Parse button + importStringGroup:AddWidget(GUI:CreateButton(self.child, L["Parse String"], 240, 26, function() + if not self.importEditBox then return end + local str = self.importEditBox:GetText() + if not str or str == "" then + DF:Err("Paste a string first.") + return + end + + local importData, errMsg = DF:ValidateImportString(str) + if not importData then + DF:Err(errMsg) + if self.importInfoLabel then self.importInfoLabel:SetText("|cffff6666Error: " .. errMsg .. "|r") end + return + end + + self.parsedImportData = importData + local info = DF:GetImportInfo(importData) + + if self.importInfoLabel then + self.importInfoLabel:SetText(string.format("|cff00ff00" .. L["OK"] .. "|r v%s %s%s", + (tostring(info.version):gsub("^[vV]", "")), + info.hasParty and L["[Party]"] or "", + info.hasRaid and L["[Raid]"] or "")) + end + + if self.importNameEdit and info.profileName then + self.importNameEdit:SetText(info.profileName) + end + + if self.createNewProfileCheck then + self.createNewProfileCheck:Enable() + self.createNewProfileCheck:SetChecked(true) + end + + local availableCats = {} + for _, cat in ipairs(info.detectedCategories) do availableCats[cat] = true end + + for cat, cb in pairs(self.importCheckboxes) do + if availableCats[cat] then cb:Enable(); cb:SetChecked(true) + else cb:Disable(); cb:SetChecked(false) end + end + + if self.importPartyCheck then + if info.hasParty then self.importPartyCheck:Enable() else self.importPartyCheck:Disable() end + self.importPartyCheck:SetChecked(info.hasParty) + end + if self.importRaidCheck then + if info.hasRaid then self.importRaidCheck:Enable() else self.importRaidCheck:Disable() end + self.importRaidCheck:SetChecked(info.hasRaid) + end + + DF:Say("Parsed. Select options and Import.") + end), 30) + + -- Info label + local infoLabel = self.child:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + infoLabel:SetWidth(240) + infoLabel:SetJustifyH("LEFT") + infoLabel:SetText("|cff888888" .. L["Paste string above, then Parse"] .. "|r") + self.importInfoLabel = infoLabel + + local infoContainer = CreateFrame("Frame", nil, self.child) + infoContainer:SetSize(240, 18) + infoLabel:SetParent(infoContainer) + infoLabel:SetPoint("LEFT", 0, 0) + importStringGroup:AddWidget(infoContainer, 22) + + AddToSection(importStringGroup, nil, 2) + + -- "What to Import" group: the target profile, the mode and the categories. + -- Mirrors the export side; see the note there on why "Import Settings" went. + local importSettingsGroup = GUI:CreateSettingsGroup(self.child, 280) + importSettingsGroup:AddWidget(GUI:CreateHeader(self.child, L["What to Import"]), 40) + + -- Profile name input for import + local impNameInput = GUI:CreateInput(self.child, L["Profile Name"], 240) + impNameInput.EditBox:SetText(L["Imported Profile"]) + self.importNameEdit = impNameInput.EditBox + importSettingsGroup:AddWidget(impNameInput, 50) + + -- Create new profile checkbox + local createNewRow = CreateFrame("Frame", nil, self.child) + createNewRow:SetSize(240, 20) + + local createNewCheck = CreateSmallCheckbox(createNewRow, L["Create New Profile"], true) + createNewCheck:SetPoint("LEFT", 0, 0) + createNewCheck:Disable() + self.createNewProfileCheck = createNewCheck + importSettingsGroup:AddWidget(createNewRow, 24) + + -- Frame types row ("Import for" -- which mode receives the settings) + importSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Import for"], 240), 22) + local ftRowImp = CreateFrame("Frame", nil, self.child) + ftRowImp:SetSize(240, 20) + + local partyImp = CreateSmallCheckbox(ftRowImp, L["Party"], false) + partyImp:SetPoint("LEFT", 0, 0) + partyImp:Disable() + partyImp.checkbox:SetScript("OnClick", function(s) self.importFrameTypes.party = s:GetChecked() end) + self.importPartyCheck = partyImp + + local raidImp = CreateSmallCheckbox(ftRowImp, L["Raid"], false) + raidImp:SetPoint("LEFT", 80, 0) + raidImp:Disable() + raidImp.checkbox:SetScript("OnClick", function(s) self.importFrameTypes.raid = s:GetChecked() end) + self.importRaidCheck = raidImp + importSettingsGroup:AddWidget(ftRowImp, 24) + + -- Categories ("Settings to include") + importSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Settings to include"], 240), 22) + for _, cat in ipairs(categoryOrder) do + local info = DF.ExportCategoryInfo[cat] + local catRow = CreateFrame("Frame", nil, self.child) + catRow:SetSize(240, 18) + + local cb = CreateSmallCheckbox(catRow, L[info.name], false) + cb:SetPoint("LEFT", 0, 0) + cb:Disable() + self.importCheckboxes[cat] = cb + importSettingsGroup:AddWidget(catRow, 20) + end + + AddToSection(importSettingsGroup, nil, 2) + + -- Import Actions Group + local importActionsGroup = GUI:CreateSettingsGroup(self.child, 280) + importActionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Import"]), 40) + + -- Import button + importActionsGroup:AddWidget(GUI:CreateIconButton(self.child, "download", L["Import Selected"], 240, 26, function() + if not self.parsedImportData then + DF:Err("Parse a string first.") + return + end + + local selectedCats = {} + for _, cat in ipairs(categoryOrder) do + if self.importCheckboxes[cat]:GetChecked() then + table.insert(selectedCats, cat) + end + end + + if #selectedCats == 0 then + DF:Err("Select at least one category.") + return + end + + local selectedFrameTypes = { + party = self.importPartyCheck:GetChecked(), + raid = self.importRaidCheck:GetChecked(), + } + + if not selectedFrameTypes.party and not selectedFrameTypes.raid then + DF:Err("Select Party or Raid.") + return + end + + local createNew = self.createNewProfileCheck and self.createNewProfileCheck:GetChecked() + local profileName = self.importNameEdit and self.importNameEdit:GetText() + if profileName == "" then profileName = nil end + + local confirmText + if createNew then + confirmText = L["Create new profile '"] .. (profileName or L["Imported Profile"]) .. L["'?\n\nThis will copy your current settings, then apply the selected import categories on top."] + else + local currentProfile = DF:GetCurrentProfile() or "Default" + confirmText = L["Import settings into current profile?\n\n"] .. "|c" .. GUI:ToneHex("danger") .. L["WARNING: This will permanently overwrite settings in your '"] .. currentProfile .. L["' profile."] .. "|r\n\n" .. L["Tip: Check 'Create New Profile' to import without affecting your current settings."] + end + + -- Everything the accept needs is captured here rather than stapled + -- onto the dialog afterwards, so there is no window in which the + -- popup exists without its payload. + local importData = self.parsedImportData + DF:ShowPopupAlert({ + title = L["Import Profile"], + message = confirmText, + buttons = { + { + label = L["Import"], + onClick = function() + if not importData then return end + DF:ApplyImportedProfile(importData, selectedCats, selectedFrameTypes, profileName, createNew) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end), 32) + + -- Clear button + importActionsGroup:AddWidget(GUI:CreateIconButton(self.child, "close", L["Clear"], 240, 24, function() + if self.importEditBox then self.importEditBox:SetText("") end + if self.importInfoLabel then self.importInfoLabel:SetText("|cff888888" .. L["Paste string above, then Parse"] .. "|r") end + if self.importNameEdit then self.importNameEdit:SetText(L["Imported Profile"]) end + if self.createNewProfileCheck then self.createNewProfileCheck:Disable(); self.createNewProfileCheck:SetChecked(true) end + for _, cb in pairs(self.importCheckboxes) do cb:SetChecked(false); cb:Disable() end + if self.importPartyCheck then self.importPartyCheck:Disable(); self.importPartyCheck:SetChecked(false) end + if self.importRaidCheck then self.importRaidCheck:Disable(); self.importRaidCheck:SetChecked(false) end + self.parsedImportData = nil + end), 28) + + AddToSection(importActionsGroup, nil, 2) + + -- See Also + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "profiles_manage", label = L["Manage Profiles"]}, + }), 30, "both") + end) + + -- ======================================== + -- CATEGORY: Wizards + -- ======================================== + -- Wizards category hidden for now (builder still in development) + -- CreateCategory("wizards", "Wizards") + + -- Wizards > Setup Wizards (launcher/manager page) — disabled while category is hidden + + -- ======================================== + -- CATEGORY: Debug + -- ======================================== + CreateCategory("debug", L["Debug"]) + + -- Single page containing four collapsible sections in workflow order: + -- Settings -> Categories -> Live Log -> Script Runner. + -- All sections are collapsible and start expanded. + local pageDebugConsole = CreateSubTab("debug", "debug_console", L["Console"]) + BuildPage(pageDebugConsole, function(self, db, Add, AddSpace, AddSyncPoint) + + -- Proxy for dropdown/slider keys (they don't support customGet/customSet) + local debugProxy = setmetatable({}, { + __index = function(_, k) + return DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug[k] + end, + __newindex = function(_, k, v) + if DandersFramesDB_v2 and DandersFramesDB_v2.debug then + DandersFramesDB_v2.debug[k] = v + end + end, + }) + + -- Tracks the currently-open collapsible section so AddToSection() can + -- automatically register subsequent widgets as its children. + local currentSection = nil + + local function AddToSection(widget, height, col) + Add(widget, height, col) + if currentSection then + currentSection:RegisterChild(widget) + end + return widget + end + + -- ============================================================ + -- 1) SETTINGS SECTION + -- ============================================================ + local settingsSection = Add(GUI:CreateCollapsibleSection(self.child, L["Settings"], true), 36, "both") + currentSection = settingsSection + + AddToSection(GUI:CreateCheckbox(self.child, L["Enable Debug Logging"], nil, nil, function() + if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end + end, function() + return DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.enabled or false + end, function(val) + if DF.DebugConsole then + DF.DebugConsole:SetEnabled(val) + elseif DandersFramesDB_v2 and DandersFramesDB_v2.debug then + DandersFramesDB_v2.debug.enabled = val + end + end), 28, "both") + + AddToSection(GUI:CreateCheckbox(self.child, L["Echo to Chat"], nil, nil, nil, function() + return DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.chatEcho or false + end, function(val) + if DandersFramesDB_v2 and DandersFramesDB_v2.debug then + DandersFramesDB_v2.debug.chatEcho = val + end + end), 28, "both") + + local logLevelOptions = { + ["INFO"] = L["Info (All)"], + ["WARN"] = L["Warnings + Errors"], + ["ERROR"] = L["Errors Only"], + } + AddToSection(GUI:CreateDropdown(self.child, L["Minimum Log Level"], logLevelOptions, debugProxy, "logLevel", function() + if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end + end), 55, 1) + + AddToSection(GUI:CreateSlider(self.child, L["Max Log Entries"], 100, 10000, 100, debugProxy, "maxLines", function() + if DF.DebugConsole then + DF.DebugConsole:PruneLog() + DF.DebugConsole:RefreshDisplay() + end + end), 55, 2) + + -- The log lives in SavedVariables, so one left behind is re-read from disk at + -- every login until something clears it. 0 = keep forever, for anyone chasing + -- a bug that only shows up across several days. + -- "0 = never" is in the LABEL because CreateSlider has no value-label map to + -- put it in: parameter 9 is `lightweightUpdate` (a per-drag-tick FUNCTION) and + -- 10 is `usePreviewMode` (the boolean that arms it). Passing a table into + -- either would read as truthy and quietly change how the slider commits. + AddToSection(GUI:CreateSlider(self.child, L["Clear Log After (Days, 0 = Never)"], + 0, 30, 1, debugProxy, "logMaxAgeDays"), 55, 1) + + AddSyncPoint() + + -- ============================================================ + -- 2) LOGGED CATEGORIES SECTION + -- ============================================================ + local categoriesSection = Add(GUI:CreateCollapsibleSection(self.child, L["Logged Categories"], true), 36, "both") + currentSection = categoriesSection + + AddToSection(GUI:CreateNote(self.child, + L["Unchecked categories are not logged at all. Disable noisy categories before reproducing a bug to keep the buffer focused."], + { width = 540 }), 36, "both") + + + local function CollectAllCategories() + local set = {} + if DF.DebugConsole then + for _, g in ipairs(DF.DebugConsole:GetCategoryGroups()) do + for _, cat in ipairs(g.categories) do + set[cat.key] = true + end + end + for cat in pairs(DF.DebugConsole:GetKnownCategories()) do + set[cat] = true + end + end + return set + end + + -- Track all created rows so All/None can refresh their visual state + self.filterRows = {} + local function RefreshAllRows() + for _, row in pairs(self.filterRows) do + if row.RefreshState then row:RefreshState() end + end + if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end + end + + local function SetAllFilters(value) + if not (DandersFramesDB_v2 and DandersFramesDB_v2.debug) then return end + local filters = DandersFramesDB_v2.debug.filters + for cat in pairs(CollectAllCategories()) do + filters[cat] = value + end + RefreshAllRows() + end + + local filterBtnRow = GUI:CreateButtonRow(self.child, { + { label = L["All"], width = 60, onClick = function() SetAllFilters(true) end }, + { label = L["None"], width = 60, onClick = function() SetAllFilters(false) end }, + -- The baseline: everything on except the per-frame firehoses. All/None + -- are blunt; this is the state you actually want to start an + -- investigation from, and the way back after turning things on. + { label = L["Default"], width = 80, + onClick = function() + if DF.DebugConsole and DF.DebugConsole:ApplyDefaultFilters() then + RefreshAllRows() + end + end, + tooltip = { + title = L["Default"], + lines = { + L["Turns every category on except the noisy ones, which log many lines per frame during layout and sorting."], + L["Enable those only while reproducing a layout or sorting bug."], + }, + } }, + }, { height = 22 }) + + AddToSection(filterBtnRow, 28, "both") + + -- One colour for the category-group headings, passed to CreateLabel rather + -- than baked into each string as a |c escape -- an escape inside a + -- localised string is invisible to translators and easy to unbalance. + local GROUP_HEADING_COLOR = { r = 0.93, g = 0.65, b = 0.37 } + + if DF.DebugConsole then + local groups = DF.DebugConsole:GetCategoryGroups() + for _, group in ipairs(groups) do + local groupLabel = L[group.name] or group.name + AddToSection(GUI:CreateLabel(self.child, groupLabel, 540, GROUP_HEADING_COLOR), 22, "both") + for _, cat in ipairs(group.categories) do + -- The firehoses are marked in the row itself, so "why is this + -- one off?" is answered where the user is looking rather than + -- only in the Default button's tooltip. The row renders it as + -- the shared caution icon; it used to be a "(noisy)" suffix + -- concatenated onto the description. + local row = GUI:CreateDebugCategoryRow(self.child, cat.key, cat.desc, 540, cat.noisy) + self.filterRows[cat.key] = row + AddToSection(row, 28, "both") + end + end + + -- Append auto-discovered categories that aren't in the registry + local registered = DF.DebugConsole:GetRegisteredCategorySet() + local known = DF.DebugConsole:GetKnownCategories() + local extras = {} + for cat in pairs(known) do + if not registered[cat] then + tinsert(extras, cat) + end + end + if #extras > 0 then + table.sort(extras) + AddToSection(GUI:CreateLabel(self.child, L["Discovered"], 540, GROUP_HEADING_COLOR), 22, "both") + for _, cat in ipairs(extras) do + local row = GUI:CreateDebugCategoryRow(self.child, cat, nil, 540) + self.filterRows[cat] = row + AddToSection(row, 28, "both") + end + end + end + + AddSyncPoint() + + -- ============================================================ + -- 3) LIVE LOG SECTION + -- ============================================================ + local logSection = Add(GUI:CreateCollapsibleSection(self.child, L["Live Log"], true), 36, "both") + currentSection = logSection + + -- Entry count label + local entryCountLabel = GUI:CreateLabel(self.child, "", 540) + local function UpdateEntryCount() + local count = DF.DebugConsole and DF.DebugConsole:GetLogEntryCount() or 0 + -- No |c escape: CreateLabel's default colour is already the dim body tone. + entryCountLabel:SetText(format(L["Log entries: %d"], count)) + end + UpdateEntryCount() + AddToSection(entryCountLabel, 20, "both") + + -- Action buttons row (Refresh / Clear Log / Copy to Clipboard) + local function CopyLogToClipboard() + if not DF.DebugConsole then return end + -- Was a hand-rolled dialog: ~35 lines building its own frame, backdrop, + -- title, drag handlers and close button. ☠ It also called CreateFrame + -- with the FIXED global name "DFDebugExportPopup" on every click, so a + -- second export built a second frame over the same global and orphaned + -- the first — a leak per click. The shared input popup is a singleton + -- and is the same control the click-cast profile export already uses. + DF:ShowPopupInput({ + title = L["Debug Log Export (Filtered)"], + message = L["Press Ctrl+A to select all, then Ctrl+C to copy"], + text = DF.DebugConsole:GetExportText(), + multiline = true, + readOnly = true, + cancelLabel = L["Close"], + }) + end + + local actionRow = GUI:CreateButtonRow(self.child, { + { label = L["Refresh"], width = 100, onClick = function() + if DF.DebugConsole then + DF.DebugConsole:RefreshDisplay() + UpdateEntryCount() + end + end }, + { label = L["Clear Log"], width = 100, onClick = function() + if DF.DebugConsole then + DF.DebugConsole:ClearLog() + UpdateEntryCount() + end + end }, + { label = L["Copy to Clipboard"], width = 140, onClick = CopyLogToClipboard }, + }) + + AddToSection(actionRow, 32, "both") + + -- Full-width log viewer + local logScrollContainer = GUI:CreateTextArea(self.child, { + width = 540, height = 480, + -- Typing in the log is not an edit — it just re-renders the buffer. + onTextChanged = function(_, userInput) + if userInput and DF.DebugConsole then + DF.DebugConsole:RefreshDisplay() + end + end, + }) + local logEditBox = logScrollContainer.EditBox + + AddToSection(logScrollContainer, 485, "both") + + -- Register live EditBox with DebugConsole + if DF.DebugConsole then + DF.DebugConsole:SetLiveEditBox(logEditBox) + DF.DebugConsole:RefreshDisplay() + UpdateEntryCount() + end + + -- Unregister on page hide + self:SetScript("OnHide", function() + if DF.DebugConsole then + DF.DebugConsole:SetLiveEditBox(nil) + end + end) + + AddSyncPoint() + + -- ============================================================ + -- 4) SCRIPT RUNNER SECTION (developer-only utility, unrelated) + -- ============================================================ + local scriptSection = Add(GUI:CreateCollapsibleSection(self.child, L["Script Runner"], true), 36, "both") + currentSection = scriptSection + + local scriptScrollContainer = GUI:CreateTextArea(self.child, { + width = 540, height = 120, + text = (DandersFramesDB_v2 and DandersFramesDB_v2.debug + and DandersFramesDB_v2.debug.lastScript) or nil, + onTextChanged = function(text, userInput) + if userInput and DandersFramesDB_v2 and DandersFramesDB_v2.debug then + DandersFramesDB_v2.debug.lastScript = text + end + end, + }) + local scriptEditBox = scriptScrollContainer.EditBox + + AddToSection(scriptScrollContainer, 125, "both") + + local scriptStatusLabel = GUI:CreateLabel(self.child, "", 540) + AddToSection(scriptStatusLabel, 20, "both") + + -- Status is a TONE, not an ad-hoc colour: these were five hand-picked hex + -- values that drifted from the info/caution/danger/success language every + -- banner, note and tooltip in the GUI already speaks. ToneHex is the one + -- source for the inline form. + local function SetScriptStatus(text, tone) + if tone then + scriptStatusLabel:SetText("|c" .. GUI:ToneHex(tone) .. text .. "|r") + else + scriptStatusLabel:SetText(text) -- default dim body tone + end + end + + AddToSection(GUI:CreateButton(self.child, L["Run Script"], 540, 26, function() + local code = scriptEditBox:GetText() + if not code or code == "" then + SetScriptStatus(L["No script to run."]) + return + end + local fn, err = loadstring(code) + if not fn then + SetScriptStatus(format(L["Error: %s"], tostring(err)), "danger") + DF:DebugError("SCRIPT", "Compile error: %s", tostring(err)) + return + end + local ok, result = pcall(fn) + if ok then + if result ~= nil then + SetScriptStatus(format(L["Result: %s"], tostring(result)), "info") + else + SetScriptStatus(L["Script executed successfully."], "success") + end + else + SetScriptStatus(format(L["Runtime: %s"], tostring(result)), "danger") + DF:DebugError("SCRIPT", "Runtime error: %s", tostring(result)) + end + end), 32, "both") + + currentSection = nil + end) + +end diff --git a/GUI/Pages/Options.lua b/GUI/Pages/Options.lua index 115f4396..74434cd8 100644 --- a/GUI/Pages/Options.lua +++ b/GUI/Pages/Options.lua @@ -1,9680 +1,2010 @@ -local addonName, DF = ... -local format = string.format - --- ============================================================ --- GUI PAGE SETUP - Collapsible Category System --- ============================================================ - -function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) - local L = DF.L - - -- Note-style cross-link dropped in under a "Color by Time Remaining" toggle so users can - -- jump to where the shared, account-wide breakpoint colours actually live (and see that - -- section highlighted on arrival). `group` is the settings group the toggle sits in; `parent` - -- is the page child frame. The link itself is GUI:CreateColorsPageLink (shared with the Aura Designer). - local function AddColorsPageLink(group, parent) - -- Shared note-style cross-link to the Colors page Color-by-Time section (jump + whole- - -- section border flash). CreateLink is fixed-layout, so hand it the group's inner width - -- up front — its wrapped height is then known before AddWidget (the group advances Y by - -- the height we pass). Defined once in GUI:CreateColorsPageLink; shared with the Aura Designer. - local innerW = math.max(40, (group:GetWidth() or 260) - 2 * (group.padding or 10)) - local note = GUI:CreateColorsPageLink(parent, innerW) - group:AddWidget(note, (note.layoutHeight or 16) + 2) - return note - end - - -- Helper function to create a themed "Copy to Raid/Party" button for a - -- section, plus the Sync toggle and the destructive Reset Page button. - -- - -- Only for a section that is a BAG OF SETTINGS spread over many db keys — - -- keeping those two bags in step is the whole point of a persistent link. A - -- section holding ONE key the user can already set directly does not need - -- it: the Aura and Text Designers own one key each (the template name), and - -- their template dropdown IS the sharing control, so they carry neither - -- button. See GUI:CreateDesignerPresetBar. - local function CreateCopyButton(parent, prefixes, sectionName, pageId) - -- Register section in the sync registry - if pageId then - DF.SectionRegistry[pageId] = prefixes - end - - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - btn:SetSize(115, 26) - -- Copy is a normal button; the shared styler owns the backdrop/hover AND - -- the icon+label layout via the icon/text opts. (Label set per-mode below.) - GUI:StyleButton(btn, { - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\content_copy", size = 18, color = { r = 0.9, g = 0.9, b = 0.9 } }, - text = L["Copy to Raid"], - }) - - -- Sync toggle button - local linkBtn - if pageId then - linkBtn = CreateFrame("Button", nil, btn, "BackdropTemplate") - linkBtn:SetSize(120, 26) - -- Snapped gap: the row is a CHAIN (Copy <- Sync <- Reset) and controls - -- are no longer nudged onto the grid after the fact, so the offset - -- itself has to be a whole number of device pixels or every button to - -- the left of this one inherits the fraction. - linkBtn:SetPoint("RIGHT", btn, "LEFT", GUI.SnapLen(linkBtn, -4), 0) - -- Sync is a toggle (SetActive when linked) on the shared styler. - -- fadeActiveText: the synced label recedes slightly while linked. - GUI:StyleButton(linkBtn, { - fadeActiveText = true, - -- icon swaps sync / sync_disabled with the linked state (set in UpdateAppearance) - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync_disabled", size = 18, color = { r = 0.9, g = 0.9, b = 0.9 } }, - text = L["Sync with Raid"], - }) - end - - -- Update appearance based on current mode - local function UpdateAppearance() - local mode = GUI.SelectedMode or "party" - - if mode == "party" then - btn.Text:SetText(L["Copy to Raid"]) - else - btn.Text:SetText(L["Copy to Party"]) - end - -- Content-size so short labels aren't swimming in padding (icon+gap ~18 + ~9px each side). - btn:SetWidth(GUI.SnapLenUp(btn, math.ceil(btn.Text:GetStringWidth()) + 36)) - - -- Copy is a normal button; StyleButton owns its backdrop/hover. - btn.Text:SetTextColor(0.9, 0.9, 0.9) - btn.Icon:SetVertexColor(0.9, 0.9, 0.9) - - -- Update sync button appearance - if linkBtn then - local dest = mode == "party" and L["Raid"] or L["Party"] - local isLinked = DF.db and DF.db.linkedSections and DF.db.linkedSections[pageId] - -- State shown by the toggle border/fill (SetActive) + the label - -- word; text stays white in both states, like the other toggles. - linkBtn:SetActive(isLinked) - linkBtn.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. (isLinked and "sync" or "sync_disabled")) - linkBtn.Text:SetText(isLinked and format(L["Synced with %s"], dest) or format(L["Sync with %s"], dest)) - linkBtn.Text:SetTextColor(0.9, 0.9, 0.9) - linkBtn.Icon:SetVertexColor(0.9, 0.9, 0.9) - linkBtn:SetWidth(GUI.SnapLenUp(linkBtn, math.ceil(linkBtn.Text:GetStringWidth()) + 36)) - end - end - - -- Store for refresh - btn.UpdateModeText = UpdateAppearance - btn.rightAlign = true -- Flag for layout system - - -- Copy tooltip (HookScript so it composes with the StyleButton hover). - btn:HookScript("OnEnter", function(self) - local mode = GUI.SelectedMode or "party" - local src = mode == "party" and L["Party"] or L["Raid"] - local dest = mode == "party" and L["Raid"] or L["Party"] - GUI:ShowTooltip(self, { - title = format(L["Copy %s Settings"], sectionName), - lines = { - format(L["Copies these settings from %s to %s."], src, dest), - }, - }) - end) - btn:HookScript("OnLeave", function() - GUI:HideTooltip() - UpdateAppearance() -- keep the Copy/Sync labels fresh after state changes - end) - - btn:SetScript("OnClick", function() - local mode = GUI.SelectedMode or "party" - local dest = mode == "party" and L["Raid"] or L["Party"] - DF:ShowPopupAlert({ - title = format(L["Copy %s Settings"], sectionName), - message = format(L["Copy %s settings to %s?"], sectionName, dest), - buttons = { - { - label = L["Copy"], - onClick = function() - DF:CopySectionSettings(prefixes, mode) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end) - - -- Sync button event handlers - if linkBtn then - linkBtn:HookScript("OnEnter", function(self) - local isLinked = DF.db and DF.db.linkedSections and DF.db.linkedSections[pageId] - if isLinked then - GUI:ShowTooltip(self, { - title = format(L["Synced: %s"], sectionName), - lines = { - format(L["Party & Raid %s settings are synced.\nClick to stop syncing."], sectionName), - }, - }) - else - GUI:ShowTooltip(self, { - title = format(L["Sync: %s"], sectionName), - lines = { - format(L["Click to sync Party & Raid %s settings.\nChanges in one mode will automatically apply to the other."], sectionName), - }, - }) - end - end) - linkBtn:HookScript("OnLeave", function() - GUI:HideTooltip() - UpdateAppearance() -- re-read the synced state so the label updates without /reload - end) - - linkBtn:SetScript("OnClick", function() - if not DF.db then return end - if not DF.db.linkedSections then DF.db.linkedSections = {} end - - if DF.db.linkedSections[pageId] then - DF.db.linkedSections[pageId] = nil - UpdateAppearance() - else - local mode = GUI.SelectedMode or "party" - local dest = mode == "party" and L["Raid"] or L["Party"] - DF:ShowPopupAlert({ - title = format(L["Sync: %s"], sectionName), - message = format(L["Sync %s settings?\n\nThis will copy current %s settings to %s and keep them in sync."], sectionName, sectionName, dest), - buttons = { - { - label = L["Sync"], - onClick = function() - DF.db.linkedSections[pageId] = true - DF:CopySectionSettings(prefixes, mode) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end - end) - end - - -- Reset to defaults button (red, destructive — leftmost in the trio). - -- Static red palette (no theme listener) so it visually flags as - -- destructive vs. the theme-coloured Copy/Sync buttons. - local resetBtn = CreateFrame("Button", nil, btn, "BackdropTemplate") - resetBtn:SetSize(115, 26) - local gap = GUI.SnapLen(resetBtn, -4) -- see the Sync gap above - if linkBtn then - resetBtn:SetPoint("RIGHT", linkBtn, "LEFT", gap, 0) - else - resetBtn:SetPoint("RIGHT", btn, "LEFT", gap, 0) - end - - -- Destructive Reset: the shared danger tone owns the red label/icon + - -- red hover; we keep the content-fit width + the tooltip hook. - GUI:StyleButton(resetBtn, { - tone = "danger", - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = 18 }, - text = L["Reset Page"], - }) - resetBtn:SetWidth(GUI.SnapLenUp(resetBtn, math.ceil(resetBtn.Text:GetStringWidth()) + 36)) - - resetBtn:HookScript("OnEnter", function(self) - local mode = GUI.SelectedMode or "party" - local m = (mode == "party") and L["Party"] or L["Raid"] - GUI:ShowTooltip(self, { - title = format(L["Reset: %s"], sectionName), - lines = { - format(L["Reset %s settings on %s mode to defaults. Other settings are not affected."], sectionName, m), - }, - }) - end) - resetBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) - - resetBtn:SetScript("OnClick", function() - local mode = GUI.SelectedMode or "party" - local m = (mode == "party") and L["Party"] or L["Raid"] - DF:ShowPopupAlert({ - title = format(L["Reset: %s"], sectionName), - message = format(L["Reset %s settings to defaults?\n\nThis only affects %s settings on the current %s mode. This cannot be undone."], sectionName, sectionName, m), - buttons = { - { - label = L["Reset"], - onClick = function() - DF:ResetSectionSettings(prefixes, mode) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end) - - -- Initial update - UpdateAppearance() - - -- Register for theme updates - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, {UpdateTheme = UpdateAppearance}) - - return btn - end - - -- Expose for use by sub-pages (e.g. Aura Designer) - GUI.CreateCopyButton = CreateCopyButton - - -- Standalone Reset button for pages whose settings aren't mode-specific - -- and so don't get the full Sync/Copy trio. Uses the same red palette and - -- confirmation popup style as the trio's Reset button. - -- - -- parent: widget parent (typically self.child inside BuildPage) - -- sectionName: localised page name, used in the popup and tooltip - -- onReset: callback that performs the actual reset; the helper - -- handles the confirmation popup before invoking this - -- warningText: optional extra line below the standard message (string) - local function CreateResetOnlyButton(parent, sectionName, onReset, warningText) - local resetBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") - resetBtn:SetSize(115, 26) - resetBtn.rightAlign = true - - -- Destructive Reset: shared danger tone (red label/icon + red hover) + - -- content-fit width + tooltip hook. - GUI:StyleButton(resetBtn, { - tone = "danger", - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = 18 }, - text = L["Reset Page"], - }) - resetBtn:SetWidth(GUI.SnapLenUp(resetBtn, math.ceil(resetBtn.Text:GetStringWidth()) + 36)) - - resetBtn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { - title = format(L["Reset: %s"], sectionName), - lines = { - format(L["Reset %s settings to defaults. This cannot be undone."], sectionName), - }, - }) - end) - resetBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) - - resetBtn:SetScript("OnClick", function() - local message = format(L["Reset %s settings to defaults?\n\nThis cannot be undone."], sectionName) - if warningText and warningText ~= "" then - message = format(L["Reset %s settings to defaults?\n\n%s\n\nThis cannot be undone."], sectionName, warningText) - end - DF:ShowPopupAlert({ - title = format(L["Reset: %s"], sectionName), - message = message, - buttons = { - { - label = L["Reset"], - onClick = function() - if onReset then onReset() end - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end) - - return resetBtn - end - - GUI.CreateResetOnlyButton = CreateResetOnlyButton - - -- Define category order (updated structure) - GUI.CategoryOrder = {"general", "clickcast", "display", "bars", "text", "auras", "indicators", "profiles", "debug"} - - -- ======================================== - -- CATEGORY: General - -- ======================================== - CreateCategory("general", L["General"]) - - -- ======================================== - -- CATEGORY: Display (new top-level category) - -- ======================================== - CreateCategory("display", L["Display"]) - - -- Display > Visibility - local pageVisibility = CreateSubTab("display", "display_visibility", L["Visibility"]) - BuildPage(pageVisibility, function(self, db, Add, AddSpace, AddSyncPoint) - Add(CreateCopyButton(self.child, {"soloMode", "hidePlayerFrame", "restedIndicator"}, L["Visibility"], "display_visibility"), 25, 2) - - -- ===== FRAME DISPLAY GROUP (Column 1) ===== - local frameDisplayGroup = GUI:CreateSettingsGroup(self.child, 280) - frameDisplayGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Display"]), 40) - - local soloMode = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Solo Mode"], db, "soloMode", function() - DF:UpdateAllFrames() - DF:UpdateDefaultPlayerFrame() - end), 30) - soloMode.hideOn = function() return GUI.SelectedMode == "raid" end - - local restedIndicator = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Rested Indicator"], db, "restedIndicator", function() - DF:UpdateRestedIndicator() - end), 30) - restedIndicator.hideOn = function() return GUI.SelectedMode == "raid" end - restedIndicator.disableOn = function(d) return not d.soloMode end - restedIndicator.tooltip = L["Show rested indicators when in a rested area (inn, city)."] - - local restedIcon = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L[" Show ZZZ Icon"], db, "restedIndicatorIcon", function() - DF:UpdateRestedIndicator() - end), 30) - restedIcon.hideOn = function() return GUI.SelectedMode == "raid" end - restedIcon.disableOn = function(d) return not d.soloMode or not d.restedIndicator end - restedIcon.tooltip = L["Show the animated ZZZ icon on the player frame."] - - local restedGlow = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L[" Show Frame Glow"], db, "restedIndicatorGlow", function() - DF:UpdateRestedIndicator() - end), 30) - restedGlow.hideOn = function() return GUI.SelectedMode == "raid" end - restedGlow.disableOn = function(d) return not d.soloMode or not d.restedIndicator end - restedGlow.tooltip = L["Show a pulsing yellow glow around the frame."] - - local soloNote = frameDisplayGroup:AddWidget(GUI:CreateLabel(self.child, L["Solo Mode: Show your player frame when not in a group."], 250), 30) - soloNote.hideOn = function() return GUI.SelectedMode == "raid" end - - local hidePlayer = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Self from Party Frames"], db, "hidePlayerFrame", function() - -- Update the secure header's showPlayer attribute - if not InCombatLockdown() and DF.partyHeader then - DF.partyHeader:SetAttribute("showPlayer", not db.hidePlayerFrame) - end - -- Reapply header settings to reposition frames - if DF.ApplyHeaderSettings then - DF:ApplyHeaderSettings() - end - DF:UpdateAllFrames() - end), 30) - hidePlayer.hideOn = function() return GUI.SelectedMode == "raid" end - hidePlayer.tooltip = L["Removes your player frame from the DandersFrames party display."] - - Add(frameDisplayGroup, nil, 1) - end) - -- The Visibility tab is entirely party/solo-oriented, so hide it in raid mode. - if GUI.Tabs and GUI.Tabs["display_visibility"] then - GUI.Tabs["display_visibility"].partyOnly = true - end - - -- Display > Tooltips (moved from General) - local pageTooltips = CreateSubTab("display", "display_tooltips", L["Tooltips"]) - BuildPage(pageTooltips, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top right (positioned automatically via rightAlign) - Add(CreateCopyButton(self.child, {"tooltip"}, L["Tooltips"], "display_tooltips"), 25, 2) - - -- ⚠ Every box on this page has BOTH an "Anchor To" and an "Anchor", which - -- is the one genuinely confusing thing here: the first picks WHAT the - -- tooltip attaches to, the second WHERE on it. Written once and applied to - -- all five pairs rather than five times over. - local TIP_ANCHOR_TO = L["What the tooltip attaches to. Game Default hands it back to Blizzard's own placement; Cursor follows the mouse; Unit Frame pins it to the frame you are hovering."] - local TIP_ANCHOR_POS = L["Which point of the thing above the tooltip hangs from. Greyed out under Game Default, because Blizzard is placing it."] - - -- Anchor position values (shared) - local anchorPositionValues = { - TOPLEFT = L["Top Left"], - TOP = L["Top"], - TOPRIGHT = L["Top Right"], - LEFT = L["Left"], - CENTER = L["Center"], - RIGHT = L["Right"], - BOTTOMLEFT = L["Bottom Left"], - BOTTOM = L["Bottom"], - BOTTOMRIGHT = L["Bottom Right"], - } - - -- ===== ROW 1: Frame Tooltips + Buff Tooltips ===== - - -- Frame Tooltips (Column 1) - local frameTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - frameTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Tooltips"]), 40) - local frameTooltipEnable = frameTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Frame Tooltips"], db, "tooltipFrameEnabled", nil), 30) - frameTooltipEnable.keepEnabled = true - frameTooltipGroup.disableChildrenOn = function(d) return not d.tooltipFrameEnabled end - frameTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipFrameDisableInCombat", function() end), 30) - - local frameAnchorValues = { - DEFAULT = L["Game Default"], - CURSOR = L["Cursor"], - FRAME = L["Unit Frame"], - } - local frameAnchorTo = frameTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], frameAnchorValues, db, "tooltipFrameAnchor", function() GUI:RefreshCurrentPage() end), 55) - frameAnchorTo.tooltip = TIP_ANCHOR_TO - - local frameAnchorPos = frameTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipFrameAnchorPos", function() end), 55) - frameAnchorPos.disableOn = function(d) return d.tooltipFrameAnchor == "DEFAULT" end - frameAnchorPos.tooltip = TIP_ANCHOR_POS - - local frameOffsetX = frameTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "tooltipFrameX", function() end), 55) - frameOffsetX.disableOn = function(d) return d.tooltipFrameAnchor ~= "FRAME" end - - local frameOffsetY = frameTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "tooltipFrameY", function() end), 55) - frameOffsetY.disableOn = function(d) return d.tooltipFrameAnchor ~= "FRAME" end - - Add(frameTooltipGroup, nil, 1) - - -- Binding Tooltips (Column 2) — pairs with Frame Tooltips: both anchor to - -- the Unit Frame, so they are the two boxes describing the SAME hover. - local bindTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - bindTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Binding Tooltips"]), 40) - local bindTooltipEnable = bindTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Binding Tooltips"], db, "tooltipBindingEnabled", nil), 30) - bindTooltipEnable.keepEnabled = true - bindTooltipGroup.disableChildrenOn = function(d) return not d.tooltipBindingEnabled end - bindTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipBindingDisableInCombat", function() end), 30) - - local bindAnchorValues = { - DEFAULT = L["Game Default"], - CURSOR = L["Cursor"], - FRAME = L["Unit Frame"], - } - local bindAnchorTo = bindTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], bindAnchorValues, db, "tooltipBindingAnchor", function() GUI:RefreshCurrentPage() end), 55) - bindAnchorTo.tooltip = TIP_ANCHOR_TO - - local bindAnchorPos = bindTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipBindingAnchorPos", function() end), 55) - bindAnchorPos.disableOn = function(d) return d.tooltipBindingAnchor == "DEFAULT" end - bindAnchorPos.tooltip = TIP_ANCHOR_POS - - local bindOffsetX = bindTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "tooltipBindingX", function() end), 55) - bindOffsetX.disableOn = function(d) return d.tooltipBindingAnchor ~= "FRAME" end - - local bindOffsetY = bindTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "tooltipBindingY", function() end), 55) - bindOffsetY.disableOn = function(d) return d.tooltipBindingAnchor ~= "FRAME" end - - Add(bindTooltipGroup, nil, 2) - - -- Buff Tooltips (Column 1) - local buffTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - buffTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Buff Tooltips"]), 40) - -- 12.1 factory rows read all of these on the layout-version bump: the Enable - -- toggle is structural (mouse-motion opt-in, in the row sig -> Rebuild), while - -- anchor/offsets/combat-hide ride style.tooltip and restyle in place - -- (SetTooltipAnchorPoint/SetHideTooltipInCombat are live mixin state, 68914+). - local RefreshAuraTooltips = function() - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - DF:UpdateAllFrames() - end - local buffTooltipEnable = buffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Buff Tooltips"], db, "tooltipBuffEnabled", RefreshAuraTooltips), 30) - buffTooltipEnable.keepEnabled = true - buffTooltipGroup.disableChildrenOn = function(d) return not d.tooltipBuffEnabled end - buffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipBuffDisableInCombat", RefreshAuraTooltips), 30) - - local buffAnchorValues = { - DEFAULT = L["Game Default"], - CURSOR = L["Cursor"], - FRAME = L["Buff Icon"], - } - local buffAnchorTo = buffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], buffAnchorValues, db, "tooltipBuffAnchor", function() RefreshAuraTooltips() GUI:RefreshCurrentPage() end), 55) - buffAnchorTo.tooltip = TIP_ANCHOR_TO - - local buffAnchorPos = buffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipBuffAnchorPos", RefreshAuraTooltips), 55) - buffAnchorPos.disableOn = function(d) return d.tooltipBuffAnchor == "DEFAULT" end - buffAnchorPos.tooltip = TIP_ANCHOR_POS - - local buffOffsetX = buffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "tooltipBuffX", RefreshAuraTooltips), 55) - buffOffsetX.disableOn = function(d) return d.tooltipBuffAnchor ~= "FRAME" end - - local buffOffsetY = buffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "tooltipBuffY", RefreshAuraTooltips), 55) - buffOffsetY.disableOn = function(d) return d.tooltipBuffAnchor ~= "FRAME" end - - Add(buffTooltipGroup, nil, 1) - - -- ⚠ NO sync points between these boxes. Every tooltip box bar Resurrection - -- is the same 320 tall by construction (header 40 + enable 30 + combat 30 - -- + two dropdowns at 55 + two sliders at 55), so the two columns stay level - -- on their own — the old "align row N" sync points bought nothing, and one - -- of them actively hurt: column 2 carries Resurrection (70) as a third box, - -- so syncing after it dropped BOTH columns to column 2's bottom and left a - -- ~70px hole in column 1 between Debuff and Binding. - -- - -- 5 boxes of 320 plus one of 70 cannot be split evenly, so the short box - -- belongs at the bottom of the SHORTER column (column 2), where a trailing - -- gap reads as the end of a column rather than as a mistake. - - -- Debuff Tooltips (Column 1) - local debuffTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - debuffTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Debuff Tooltips"]), 40) - local debuffTooltipEnable = debuffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Debuff Tooltips"], db, "tooltipDebuffEnabled", RefreshAuraTooltips), 30) - debuffTooltipEnable.keepEnabled = true - debuffTooltipGroup.disableChildrenOn = function(d) return not d.tooltipDebuffEnabled end - debuffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipDebuffDisableInCombat", RefreshAuraTooltips), 30) - - local debuffAnchorValues = { - DEFAULT = L["Game Default"], - CURSOR = L["Cursor"], - FRAME = L["Debuff Icon"], - } - local debuffAnchorTo = debuffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], debuffAnchorValues, db, "tooltipDebuffAnchor", function() RefreshAuraTooltips() GUI:RefreshCurrentPage() end), 55) - debuffAnchorTo.tooltip = TIP_ANCHOR_TO - - local debuffAnchorPos = debuffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipDebuffAnchorPos", RefreshAuraTooltips), 55) - debuffAnchorPos.disableOn = function(d) return d.tooltipDebuffAnchor == "DEFAULT" end - debuffAnchorPos.tooltip = TIP_ANCHOR_POS - - local debuffOffsetX = debuffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "tooltipDebuffX", RefreshAuraTooltips), 55) - debuffOffsetX.disableOn = function(d) return d.tooltipDebuffAnchor ~= "FRAME" end - - local debuffOffsetY = debuffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "tooltipDebuffY", RefreshAuraTooltips), 55) - debuffOffsetY.disableOn = function(d) return d.tooltipDebuffAnchor ~= "FRAME" end - - Add(debuffTooltipGroup, nil, 2) - - -- Defensive Icon Tooltips (Column 1) - local defTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - defTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Defensive Icon Tooltips"]), 40) - local defTooltipEnable = defTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Defensive Icon Tooltips"], db, "tooltipDefensiveEnabled", RefreshAuraTooltips), 30) - defTooltipEnable.keepEnabled = true - defTooltipGroup.disableChildrenOn = function(d) return not d.tooltipDefensiveEnabled end - defTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipDefensiveDisableInCombat", RefreshAuraTooltips), 30) - - local defAnchorValues = { - DEFAULT = L["Game Default"], - CURSOR = L["Cursor"], - FRAME = L["Defensive Icon"], - } - local defAnchorTo = defTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], defAnchorValues, db, "tooltipDefensiveAnchor", function() RefreshAuraTooltips() GUI:RefreshCurrentPage() end), 55) - defAnchorTo.tooltip = TIP_ANCHOR_TO - - local defAnchorPos = defTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipDefensiveAnchorPos", RefreshAuraTooltips), 55) - defAnchorPos.disableOn = function(d) return d.tooltipDefensiveAnchor == "DEFAULT" end - defAnchorPos.tooltip = TIP_ANCHOR_POS - - local defOffsetX = defTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "tooltipDefensiveX", RefreshAuraTooltips), 55) - defOffsetX.disableOn = function(d) return d.tooltipDefensiveAnchor ~= "FRAME" end - - local defOffsetY = defTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "tooltipDefensiveY", RefreshAuraTooltips), 55) - defOffsetY.disableOn = function(d) return d.tooltipDefensiveAnchor ~= "FRAME" end - - Add(defTooltipGroup, nil, 1) - - -- Aura Designer Tooltips (Column 2). Lives HERE rather than on the Aura - -- Designer page: this setting only ever gets touched by someone who - -- wants a tooltip and hasn't got one, or has one and doesn't want it — - -- and both of those people go looking for "tooltip". The AD page links - -- across via its See Also row. - -- - -- Split by AD surface rather than one blanket toggle, because the value - -- differs sharply. GROUPS render whatever matches a filter, so you never - -- chose those icons — that is the case worth having. Indicators and Bars - -- are spells you placed and named yourself, so they gain little, but - -- there's no harm in offering them. - local adTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - adTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Aura Designer Tooltips"]), 40) - local adGroupsTip = adTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Groups"], db, "tooltipADGroupsEnabled", RefreshAuraTooltips), 30) - adGroupsTip.tooltip = L["Filter Groups and Debuff Groups. Their icons come from a filter rather than being placed one by one, so a tooltip is the only way to see what each one is."] - local adIndTip = adTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Indicators"], db, "tooltipADIndicatorsEnabled", RefreshAuraTooltips), 30) - adIndTip.tooltip = L["Icons and squares you placed yourself. You already chose these, so tooltips add less here."] - local adBarTip = adTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Bars"], db, "tooltipADBarsEnabled", RefreshAuraTooltips), 30) - adBarTip.tooltip = L["The Aura Designer bar."] - Add(adTooltipGroup, nil, 2) - - -- Resurrection Icon Tooltips (Column 2) — the one short box, kept last so - -- the leftover space lands at the foot of a column. - local resTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) - resTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Resurrection Icon Tooltips"]), 40) - resTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Resurrection Icon Tooltips"], db, "tooltipResurrectionEnabled", nil), 30) - Add(resTooltipGroup, nil, 2) - - -- Sync point before See Also - AddSyncPoint() - AddSpace(GUI.Space.block, "both") - - -- See Also links - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_buffs", label = L["Buffs"]}, - {pageId = "auras_debuffs", label = L["Debuffs"]}, - {pageId = "auras_defensiveicon", label = L["Defensive Icon"]}, - {pageId = "auras_auradesigner", label = L["Aura Designer"]}, - }), 30, "both") - end) - - -- Display > Fading (moved from Indicators > Out of Range + Dead/Offline fading) - local pageFading = CreateSubTab("display", "display_fading", L["Fading"]) - BuildPage(pageFading, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top right. - -- ☠ These prefixes are matched CASE-SENSITIVELY from the START of the key - -- (DF:SectionOwnsKey), and the SAME list drives Copy, Sync AND Reset Page. - -- "dead" and "offline" matched nothing: the keys are fadeDead*, which begins - -- "fade". So the entire Dead/Offline Fading box was silently dropped from all - -- three. Prefixes must be real key prefixes, not the box's name. - Add(CreateCopyButton(self.child, {"rangeFade", "rangeCheck", "rangeUpdate", "oor", "fadeDead", "healthFade", "hf"}, L["Fading"], "display_fading"), 25, 2) - - -- Element-specific alpha sliders grey out (disabled-in-place) when the - -- "Enable Element-Specific Alpha" toggle is off. The frame-level alpha - -- slider is the alternate variant (shown only when element-specific is - -- off) so it keeps a hideOn, not a grey. - local function HideOOROptions(d) - return not d.oorEnabled - end - - -- Helper to check if frame-level alpha should be hidden (when element-specific is enabled) - local function HideFrameLevelAlpha(d) - return d.oorEnabled - end - - -- ===== OUT OF RANGE GROUP (Column 1) ===== - local oorGroup = GUI:CreateSettingsGroup(self.child, 280) - oorGroup:AddWidget(GUI:CreateHeader(self.child, L["Out of Range"]), 40) - - -- Build dropdown options dynamically - local function GetRangeSpellDropdownOptions() - local options = {} - if DF.GetRangeSpellOptions then - local spellOptions = DF:GetRangeSpellOptions() - for _, opt in ipairs(spellOptions) do - options[opt.value] = opt.label - end - else - options[0] = L["Auto (Spec Default)"] - end - return options - end - - -- Ensure db value is not nil (default to 0 = Auto) - if db.rangeCheckSpellID == nil then - db.rangeCheckSpellID = 0 - end - - -- Helper to refresh info label - local function RefreshRangeInfoLabel() - if self.rangeSpellInfoLabel and self.rangeSpellInfoLabel.SetText and DF.GetCurrentRangeSpellInfo then - local info = DF:GetCurrentRangeSpellInfo() - self.rangeSpellInfoLabel:SetText("|cFFAAAAAA" .. L["Active:"] .. " " .. (info.spellName or "None") .. " (" .. (info.range or "?") .. ")|r") - end - end - - -- Set value callback - called AFTER dropdown has already set db.rangeCheckSpellID - local function SetRangeSpellValue() - local value = db.rangeCheckSpellID or 0 - if DF.SetRangeCheckSpell then - DF:SetRangeCheckSpell(value) - end - RefreshRangeInfoLabel() - if self.rangeSpellInput and self.rangeSpellInput.EditBox then - self.rangeSpellInput.EditBox:SetText("") - end - end - - -- Range Check Spell row - local rangeSpellDropdown = oorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Range Check Spell"], GetRangeSpellDropdownOptions(), db, "rangeCheckSpellID", SetRangeSpellValue), 55) - rangeSpellDropdown.tooltip = L["Select which spell to use for range checking. Auto will use your spec's default healing/friendly spell."] - - -- Custom Spell ID Input - local customSpellInput = oorGroup:AddWidget(GUI:CreateInput(self.child, L["Custom Spell ID"], 120), 55) - customSpellInput.tooltip = L["Enter any spell ID for range checking. Press Enter to apply. Leave empty to use dropdown selection."] - self.rangeSpellInput = customSpellInput - - -- Set initial value if it's a custom spell not in dropdown - if db.rangeCheckSpellID and db.rangeCheckSpellID > 0 then - local isInDropdown = false - if DF.GetRangeSpellOptions then - for _, opt in ipairs(DF:GetRangeSpellOptions()) do - if opt.value == db.rangeCheckSpellID then - isInDropdown = true - break - end - end - end - if not isInDropdown then - customSpellInput.EditBox:SetText(tostring(db.rangeCheckSpellID)) - end - end - - customSpellInput.EditBox:SetNumeric(true) - customSpellInput.EditBox:SetMaxLetters(8) - - local function ApplyCustomSpellID() - local text = customSpellInput.EditBox:GetText() - local spellID = tonumber(text) - - if not text or text == "" then - return - end - - if spellID and spellID > 0 then - local spellName = C_Spell.GetSpellName(spellID) - if spellName then - db.rangeCheckSpellID = spellID - if DF.SetRangeCheckSpell then - DF:SetRangeCheckSpell(spellID) - end - RefreshRangeInfoLabel() - DF:Say("Range spell set to " .. spellName, "ID " .. spellID) - else - DF:Err("Invalid spell ID: " .. spellID) - customSpellInput.EditBox:SetText("") - end - end - end - - customSpellInput.EditBox:SetScript("OnEnterPressed", function(self) - ApplyCustomSpellID() - self:ClearFocus() - end) - customSpellInput.EditBox:SetScript("OnEditFocusLost", function(self) - ApplyCustomSpellID() - end) - - -- Info label showing current active spell - local rangeInfoText = L["Loading..."] - if DF.GetCurrentRangeSpellInfo then - local rangeInfo = DF:GetCurrentRangeSpellInfo() - rangeInfoText = (rangeInfo.spellName or "None") .. " (" .. (rangeInfo.range or "?") .. ")" - end - local infoLabel = oorGroup:AddWidget(GUI:CreateLabel(self.child, "|cFFAAAAAA" .. L["Active:"] .. " " .. rangeInfoText .. "|r", 250), 25) - self.rangeSpellInfoLabel = infoLabel - - -- Range update interval - if db.rangeUpdateInterval == nil then - db.rangeUpdateInterval = 0.5 - end - local intervalSlider = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Range Check Interval"], 0.1, 1.0, 0.05, db, "rangeUpdateInterval", nil, function() - if DF.SetRangeUpdateInterval then - DF:SetRangeUpdateInterval(db.rangeUpdateInterval) - end - end, true), 55) - intervalSlider.tooltip = L["How often to check range (seconds). Lower = more responsive but higher CPU. Default: 0.5s"] - - -- Frame-level alpha (shown when element-specific is disabled) - local frameLevelAlpha = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Alpha (Out of Range)"], 0.1, 1.0, 0.05, db, "rangeFadeAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - frameLevelAlpha.hideOn = HideFrameLevelAlpha - - -- Element-specific toggle - oorGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Element-Specific Alpha"], db, "oorEnabled", function() - self:RefreshStates() - end), 30) - - -- Element-specific sliders (shown when enabled) - local oorHealth = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Bar Alpha"], 0.0, 1.0, 0.05, db, "oorHealthBarAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorHealth.disableOn = HideOOROptions - - local oorMissingHealth = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Missing Health Alpha"], 0.0, 1.0, 0.05, db, "oorMissingHealthAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorMissingHealth.disableOn = HideOOROptions - - local oorBg = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0.0, 1.0, 0.05, db, "oorBackgroundAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorBg.disableOn = HideOOROptions - - local oorBorder = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Alpha"], 0.0, 1.0, 0.05, db, "oorBorderAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorBorder.disableOn = HideOOROptions - - -- Unified Text Alpha: the Text Designer now renders all unit text, so a - -- single OOR alpha dims every TD text element (name/health/power/custom) - -- out of range — replacing the old per-element Name/Health text alphas. - local oorText = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Text Alpha"], 0.0, 1.0, 0.05, db, "oorTextAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorText.disableOn = HideOOROptions - - local oorAuras = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Auras Alpha"], 0.0, 1.0, 0.05, db, "oorAurasAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorAuras.disableOn = HideOOROptions - - local oorIcons = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Alpha"], 0.0, 1.0, 0.05, db, "oorIconsAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorIcons.disableOn = HideOOROptions - - local oorDispel = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Dispel Overlay Alpha"], 0.0, 1.0, 0.05, db, "oorDispelOverlayAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorDispel.disableOn = HideOOROptions - - -- My Buff Indicator OOR slider removed — feature deprecated - - local oorPower = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Power Bar Alpha"], 0.0, 1.0, 0.05, db, "oorPowerBarAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorPower.disableOn = HideOOROptions - - local oorMissingBuff = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Missing Buff Alpha"], 0.0, 1.0, 0.05, db, "oorMissingBuffAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorMissingBuff.disableOn = HideOOROptions - - local oorDefensive = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Defensive Icon Alpha"], 0.0, 1.0, 0.05, db, "oorDefensiveIconAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorDefensive.disableOn = HideOOROptions - - -- (Removed) the Targeted Spell Alpha slider on oorTargetedSpellAlpha. Its only - -- consumer was DF:UpdateTargetedSpellAppearance, which faded the group-frame - -- container and went with that display. Personal Targeted is a screen overlay - -- that never ran through ElementAppearance's out-of-range path, and the - -- Targeted List has its own container and colours — so the slider was moving - -- a value nothing read. - - local oorAuraDesigner = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Aura Designer Alpha"], 0.0, 1.0, 0.05, db, "oorAuraDesignerAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - oorAuraDesigner.disableOn = HideOOROptions - - Add(oorGroup, nil, 1) - - -- ===== DEAD/OFFLINE FADING GROUP (Column 2) ===== - local deadGroup = GUI:CreateSettingsGroup(self.child, 280) - deadGroup:AddWidget(GUI:CreateHeader(self.child, L["Dead/Offline Fading"]), 40) - - local deadFadeEnable = deadGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Dead Fade"], db, "fadeDeadFrames", function() - self:RefreshStates() - DF:UpdateAllFrames() - DF:RefreshAllVisibleFrames() - end), 30) - deadFadeEnable.keepEnabled = true - deadGroup.disableChildrenOn = function(d) return not d.fadeDeadFrames end - - -- Sliders grey out (disabled-in-place) via deadGroup.disableChildrenOn above. - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadBackground", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Bar Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadHealthBar", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Name Text Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadName", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Power Bar Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadPowerBar", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadIcons", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Auras Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadAuras", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Status Text Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadStatusText", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) - - deadGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Custom Dead Background"], db, "fadeDeadUseCustomColor", function() - self:RefreshStates() - DF:UpdateAllFrames() - DF:RefreshAllVisibleFrames() - end), 30) - - -- Colour picker also greys on the useCustomColor variant (disableOn composes - -- with the group's enable gate). - local deadBgColor = deadGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Dead Background Color"], db, "fadeDeadBackgroundColor", false, function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 35) - deadBgColor.disableOn = function(d) return not d.fadeDeadUseCustomColor end - - Add(deadGroup, nil, 2) - - -- ===== HEALTH THRESHOLD FADING (col2) ===== - -- Column width, and the spacer above it is column 2 as well. A "both" - -- widget takes the LOWER of the two columns and drops both to it, so a - -- "both" spacer here would push column 2 down past the bottom of the - -- out-of-range group in column 1 and leave a hole under Dead/Offline. - -- - -- Column 2 rather than 1 because column 1 carries the out-of-range group - -- and its long stack of per-element sliders, far and away the tallest - -- thing on the page. - AddSpace(GUI.Space.block, 2) - local hfGroup = GUI:CreateSettingsGroup(self.child, 280) - hfGroup:AddWidget(GUI:CreateHeader(self.child, L["Health Threshold Fading"]), 40) - - local hfEnable = hfGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Health Threshold Fade"], db, "healthFadeEnabled", function() - self:RefreshStates() - DF:UpdateAllFrames() - DF:RefreshAllVisibleFrames() - end), 30) - hfEnable.keepEnabled = true - -- Was set on hfGroup, which is a SettingsGroup and has no tooltip support, - -- so this explanation had never once been seen. It belongs on the enable - -- toggle anyway — that's the control you hover to ask "what is this?". - hfEnable.tooltip = L["Fade frames or elements when a unit's health is above the set threshold (e.g. 100% or 80%)."] - hfGroup.disableChildrenOn = function(d) return not d.healthFadeEnabled end - - local hfThreshold = hfGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Threshold (%)"], 50, 100, 1, db, "healthFadeThreshold", function() - DF:UpdateAllFrames() - DF:RefreshAllVisibleFrames() - end), 55) - hfThreshold.tooltip = L["Units at or above this health percent are faded."] - - hfGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Cancel Fade on Dispellable Debuff"], db, "hfCancelOnDispel", function() - DF:UpdateAllFrames() - DF:RefreshAllVisibleFrames() - end), 30) - - -- Health fade sliders need UpdateAllFrameAppearances to force an immediate visual refresh. - -- Unlike OOR/dead fade which refresh on range/state changes, health fade alpha values - -- are only re-read during appearance updates, not triggered by FullFrameRefresh alone. - local function RefreshHealthFade() - if DF.InvalidateHealthFadeCurve then DF:InvalidateHealthFadeCurve() end - DF:RefreshAllVisibleFrames() - if DF.UpdateAllFrameAppearances then DF:UpdateAllFrameAppearances() end - end - - local hfFrameAlpha = hfGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Alpha (Above Threshold)"], 0.1, 1.0, 0.05, db, "healthFadeAlpha", nil, RefreshHealthFade, true), 55) - hfFrameAlpha.tooltip = L["Frame opacity when health is above the threshold."] - - Add(hfGroup, nil, 2) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "display_visibility", label = L["Visibility"]}, - -- LEGACY-TEXT-CLEANUP: legacy text page hidden; link removed - -- {pageId = "text_status", label = L["Status Text"]}, - }), 30, "both") - end) - - -- Display > Pet Frames - local pagePets = CreateSubTab("display", "display_pets", L["Pet Frames"]) - BuildPage(pagePets, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top right - Add(CreateCopyButton(self.child, {"pet"}, L["Pet Frames"], "display_pets"), 25, 2) - - -- Check modes for conditional content - local isGroupedMode = db.petGroupMode == "GROUPED" - local isRaidMode = GUI.SelectedMode == "raid" - - -- ===== GENERAL GROUP (col1) ===== - -- Column width, not full width. A full-width box belongs to a page that - -- genuinely needs the room -- Pinned Frames, Nicknames -- and everything - -- below these two here is ordinary two-column controls, so stretching - -- them across the top reads as two different layouts stacked together. - local generalGroup = GUI:CreateSettingsGroup(self.child, 280) - generalGroup:AddWidget(GUI:CreateHeader(self.child, L["Pet Frame Settings"]), 40) - local petEnable = generalGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Pet Frames"], db, "petEnabled", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - self:RefreshStates() - end), 30) - petEnable.keepEnabled = true - generalGroup.disableChildrenOn = function(d) return not d.petEnabled end - -- No slot height on the blurbs here or in the group below: at 250 they - -- wrap to more lines than they did at 530, and CreateLabel measures - -- itself whenever the call site does not pin it. Guessing a replacement - -- number by hand is what puts a blurb through the control beneath it. - generalGroup:AddWidget(GUI:CreateLabel(self.child, L["Show health bars for player and party/raid member pets, anchored to their owner's frame. Pet frames hide when owner dies."], 250)) - Add(generalGroup, nil, 1) - - -- ===== LAYOUT MODE GROUP (col1) ===== - local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) - layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout Mode"]), 40) - layoutGroup.disableChildrenOn = function(d) return not d.petEnabled end - - local groupModeValues = { - ATTACHED = L["Attached to Owner"], - GROUPED = L["Separate Pet Group"], - } - local petLayoutMode = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Layout Mode"], groupModeValues, db, "petGroupMode", function() - if DF.UpdateAllPetFrames then DF:UpdateAllPetFrames(true) end - if DF.UpdateAllRaidPetFrames then DF:UpdateAllRaidPetFrames(true) end - GUI:RefreshCurrentPage() - end), 55) - petLayoutMode.tooltip = L["Attached puts each pet beside its owner's frame, so you read them together. Separate Pet Group collects every pet into one block you can place anywhere. The rest of this page changes to match your choice."] - - if not isGroupedMode then - layoutGroup:AddWidget(GUI:CreateLabel(self.child, L["Pet frames are positioned relative to their owner's frame."], 250)) - else - layoutGroup:AddWidget(GUI:CreateLabel(self.child, L["Pet frames are grouped together in a separate container."], 250)) - end - Add(layoutGroup, nil, 1) - - -- GROUPED MODE: Group Settings (col1) - if isGroupedMode then - local groupedSettingsGroup = GUI:CreateSettingsGroup(self.child, 280) - groupedSettingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Settings"]), 40) - groupedSettingsGroup.disableChildrenOn = function(d) return not d.petEnabled end - - local groupAnchorValues = { - BOTTOM = isRaidMode and L["Below Raid"] or L["Below Party"], - TOP = isRaidMode and L["Above Raid"] or L["Above Party"], - LEFT = isRaidMode and L["Left of Raid"] or L["Left of Party"], - RIGHT = isRaidMode and L["Right of Raid"] or L["Right of Party"], - } - local updateFunc = isRaidMode - and function() if DF.UpdateRaidPetGroupLayout then DF:UpdateRaidPetGroupLayout() end end - or function() if DF.UpdatePetGroupLayout then DF:UpdatePetGroupLayout() end end - - local petGroupPos = groupedSettingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Group Position"], groupAnchorValues, db, "petGroupAnchor", updateFunc), 55) - petGroupPos.tooltip = L["Which side of your party or raid frames the whole pet block sits on. Use the offsets below to nudge it from there."] - - local growthValues = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } - groupedSettingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], growthValues, db, "petGroupGrowth", updateFunc), 55) - groupedSettingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], 0, 20, 1, db, "petGroupSpacing", updateFunc, updateFunc, true), 55) - groupedSettingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Group X Offset"], -100, 100, 1, db, "petGroupOffsetX", updateFunc, updateFunc, true), 55) - groupedSettingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Group Y Offset"], -100, 100, 1, db, "petGroupOffsetY", updateFunc, updateFunc, true), 55) - - if isRaidMode then - groupedSettingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Group Label"], db, "petGroupShowLabel", function() - if DF.UpdateRaidPetGroupLayout then DF:UpdateRaidPetGroupLayout() end - end), 30) - end - - Add(groupedSettingsGroup, nil, 1) - end - - -- SIZE GROUP (col1) - local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) - sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Size"]), 40) - sizeGroup.disableChildrenOn = function(d) return not d.petEnabled end - - if not isGroupedMode then - local petMatchW = sizeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Match Owner Width"], db, "petMatchOwnerWidth", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - GUI:RefreshCurrentPage() - end), 30) - petMatchW.tooltip = L["Sizes each pet frame to its owner's, so the pair stays aligned when you resize the unit frames. The Width slider below greys out while this is on."] - local petMatchH = sizeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Match Owner Height"], db, "petMatchOwnerHeight", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - GUI:RefreshCurrentPage() - end), 30) - petMatchH.tooltip = L["Sizes each pet frame to its owner's, so the pair stays aligned when you resize the unit frames. The Height slider below greys out while this is on."] - end - - local widthSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Width"], 40, 150, 1, db, "petFrameWidth", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - if not isGroupedMode then - widthSlider.disableOn = function(d) return d.petMatchOwnerWidth end - end - - local heightSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 10, 40, 1, db, "petFrameHeight", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - if not isGroupedMode then - heightSlider.disableOn = function(d) return d.petMatchOwnerHeight end - end - - Add(sizeGroup, nil, 1) - - -- APPEARANCE GROUP (col2) - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - appearanceGroup.disableChildrenOn = function(d) return not d.petEnabled end - appearanceGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Health Bar Texture"], db, "petTexture", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - appearanceGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "petBackgroundColor", true, function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) - Add(appearanceGroup, nil, 2) - - -- ===== BORDER GROUP (Stage 4.3) ===== - -- include set tailored for a mini unit frame's border. Skipped: - -- animate (decoration, not alert), offset (Pet Frame has its own - -- Offset X / Y in the Position group in column 1), class / role colour - -- (UnitClass("pet") returns the pet family, not a class token), - -- colour-by-time / colour-by-type (no aura-state context). - local petBorderGroup = GUI:CreateSettingsGroup(self.child, 280) - petBorderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - petBorderGroup.disableChildrenOn = function(d) return not d.petEnabled end - GUI:CreateBorderControls(petBorderGroup, db, "pet", { - parent = self.child, - include = { alpha = true, inset = true, blendMode = true, - gradient = true, shadow = true }, - fullUpdate = function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, - lightUpdate = function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, - lightColors = function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, - refreshStates = function() self:RefreshStates() end, - sizeMin = 1, sizeMax = 6, sizeStep = 1, - }) - Add(petBorderGroup, nil, 2) - - -- HEALTH BAR GROUP (col2) - local healthBarGroup = GUI:CreateSettingsGroup(self.child, 280) - healthBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Health Bar"]), 40) - healthBarGroup.disableChildrenOn = function(d) return not d.petEnabled end - - local healthColorValues = { - GREEN = L["Always Green"], - CLASS = L["Class Color"], - HEALTH = L["Health Gradient"], - CUSTOM = L["Custom Color"], - } - healthBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Health Bar Color"], healthColorValues, db, "petHealthColorMode", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - GUI:RefreshCurrentPage() - end), 55) - - local customHealthColor = healthBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Custom Health Color"], db, "petHealthColor", false, function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) - customHealthColor.hideOn = function(d) return d.petHealthColorMode ~= "CUSTOM" end - - healthBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Health Percentage"], db, "petShowHealthText", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end), 30) - - healthBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Power Bar"], db, "petShowPowerBar", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - GUI:RefreshCurrentPage() - end), 30) - - local petPowerHeight = healthBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Power Bar Height"], 1, 12, 1, db, "petPowerBarHeight", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - petPowerHeight.disableOn = function(d) return not d.petShowPowerBar end -- grey when power bar off - - local powerColorValues = { - POWER = L["By Power Type"], - CUSTOM = L["Custom Color"], - } - local petPowerColorMode = healthBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Power Bar Color"], powerColorValues, db, "petPowerColorMode", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - GUI:RefreshCurrentPage() - end), 55) - petPowerColorMode.disableOn = function(d) return not d.petShowPowerBar end -- grey when power bar off - - local customPowerColor = healthBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Custom Power Color"], db, "petPowerColor", false, function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) - -- Grey when the power bar is off (boolean fold); HIDE only for the non-CUSTOM - -- colour mode (variant gating). The two compose: hidden in non-CUSTOM, greyed - -- in CUSTOM while the power bar is off. - customPowerColor.disableOn = function(d) return not d.petShowPowerBar end - customPowerColor.hideOn = function(d) return d.petPowerColorMode ~= "CUSTOM" end - - Add(healthBarGroup, nil, 2) - - -- NAME TEXT GROUP (col2) - local textAnchorValues = { - TOPLEFT= L["Top Left"], TOP= L["Top"], TOPRIGHT= L["Top Right"], - LEFT= L["Left"], CENTER= L["Center"], RIGHT= L["Right"], - BOTTOMLEFT= L["Bottom Left"], BOTTOM= L["Bottom"], BOTTOMRIGHT= L["Bottom Right"], - } - - local nameTextGroup = GUI:CreateSettingsGroup(self.child, 280) - nameTextGroup:AddWidget(GUI:CreateHeader(self.child, L["Name Text"]), 40) - nameTextGroup.disableChildrenOn = function(d) return not d.petEnabled end - nameTextGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "petNameFont", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 16, 1, db, "petNameFontSize", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - nameTextGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "petNameFontOutline", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - nameTextGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "petNameFontOutline", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 30) - nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Name Length"], 4, 20, 1, db, "petNameMaxLength", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end), 55) - nameTextGroup:AddWidget(GUI:CreateDropdown(self.child, L["Name Anchor"], textAnchorValues, db, "petNameAnchor", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - nameTextGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Name Text Color"], db, "petNameColor", false, function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) - nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Name X Offset"], -30, 30, 1, db, "petNameX", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Name Y Offset"], -15, 15, 1, db, "petNameY", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - Add(nameTextGroup, nil, 2) - - -- POSITION GROUP (col1, Attached mode only) - if not isGroupedMode then - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - positionGroup.disableChildrenOn = function(d) return not d.petEnabled end - - local anchorValues = { - BOTTOM = L["Below Owner"], - TOP = L["Above Owner"], - LEFT = L["Left of Owner"], - RIGHT = L["Right of Owner"], - } - positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorValues, db, "petAnchor", function() - if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end - end), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "petOffsetX", function() - if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end - end, function() if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end end, true), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "petOffsetY", function() - if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end - end, function() if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end end, true), 55) - - Add(positionGroup, nil, 1) - end - - -- HEALTH TEXT GROUP (col2) - local healthTextGroup = GUI:CreateSettingsGroup(self.child, 280) - healthTextGroup:AddWidget(GUI:CreateHeader(self.child, L["Health Text"]), 40) - healthTextGroup.disableChildrenOn = function(d) return not d.petEnabled end - healthTextGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "petHealthFont", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - healthTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 14, 1, db, "petHealthFontSize", function() - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - healthTextGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "petHealthFontOutline", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - healthTextGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "petHealthFontOutline", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 30) - healthTextGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Health Text Color"], db, "petHealthTextColor", false, function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) - healthTextGroup:AddWidget(GUI:CreateDropdown(self.child, L["Health Text Anchor"], textAnchorValues, db, "petHealthAnchor", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end), 55) - healthTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Health X Offset"], -30, 30, 1, db, "petHealthX", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - healthTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Y Offset"], -15, 15, 1, db, "petHealthY", function() - if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end - end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) - Add(healthTextGroup, nil, 2) - end) - - -- General > Settings (mode enable/disable, Blizzard frame toggles, profile-wide settings) - local pageGeneral = CreateSubTab("general", "general_settings", L["Settings"]) - BuildPage(pageGeneral, function(self, db, Add, AddSpace, AddSyncPoint) - -- Helpers: read from party-mode storage (canonical), write to BOTH - -- party and raid mode dbs so the value stays consistent regardless - -- of which mode is currently selected. The Blizzard frames are - -- global UI elements so the toggle conceptually has no mode. - local function makeBlizGet(key) - return function() return DF.db.party and DF.db.party[key] end - end - local function makeBlizSet(key, cb) - return function(val) - if DF.db.party then DF.db.party[key] = val end - if DF.db.raid then DF.db.raid[key] = val end - if cb then cb() end - end - end - - -- Contextual reload popup shown when toggling DF Party/Raid frames. - -- Offers an optional third button that ALSO flips the matching - -- Blizzard hide flag, so "enabling" DF party also disables Blizzard - -- party (typical intent) and "disabling" DF party also enables - -- Blizzard party (so the user isn't left with no frames). - local function PromptReloadAfterModeToggle(mode) - if not DF:EnableFlagsDifferFromLoaded() then return end - if not DF.ShowPopupAlert then return end - - -- NB: don't use `cond and a or b` here — the `a` result can be - -- false (when DF frames are disabled), which makes the `or` - -- fall through to the wrong mode's value. - local enabled - if mode == "party" then - enabled = DF.db.partyEnabled ~= false - else - enabled = DF.db.raidEnabled ~= false - end - local blizKey = (mode == "party") and "hideBlizzardPartyFrames" or "hideBlizzardRaidFrames" - local blizCurrentlyHidden = DF.db.party and DF.db.party[blizKey] - - local buttons = {} - if enabled and not blizCurrentlyHidden then - -- Enabling DF frames while Blizzard frames are still visible - -- → offer to disable the Blizzard equivalent on the same reload - buttons[#buttons + 1] = { - label = (mode == "party") and L["Reload & Disable Blizzard Party"] or L["Reload & Disable Blizzard Raid"], - onClick = function() - if DF.db.party then DF.db.party[blizKey] = true end - if DF.db.raid then DF.db.raid[blizKey] = true end - ReloadUI() - end, - } - elseif (not enabled) and blizCurrentlyHidden then - -- Disabling DF frames while Blizzard frames are hidden - -- → offer to re-enable the Blizzard equivalent - buttons[#buttons + 1] = { - label = (mode == "party") and L["Reload & Enable Blizzard Party"] or L["Reload & Enable Blizzard Raid"], - onClick = function() - if DF.db.party then DF.db.party[blizKey] = false end - if DF.db.raid then DF.db.raid[blizKey] = false end - ReloadUI() - end, - } - end - buttons[#buttons + 1] = { label = L["Just Reload"], onClick = function() ReloadUI() end } - buttons[#buttons + 1] = { label = L["Reload Later"] } - - DF:ShowPopupAlert({ - title = L["Reload Required"], - message = L["Enabling or disabling a frame mode requires a UI reload to take effect.\n\nReload now?"], - width = 560, - buttonWidth = 170, - buttonHeight = 44, - buttons = buttons, - }) - end - - -- The Blizzard-frame disable is applied ONCE at load (a hard, ElvUI-style - -- UnregisterAllEvents + reparent of the CompactRaidFrameManager that can't - -- be cleanly undone live), so changing any of these toggles needs a UI - -- reload to take full effect. The setter still hides/shows the frames - -- immediately for feedback; this prompt handles the permanent part. - local function PromptReloadBlizzard() - if not DF.ShowPopupAlert then return end - DF:ShowPopupAlert({ - title = L["Reload Required"], - message = L["Disabling or enabling the Blizzard frames requires a UI reload to take full effect.\n\nReload now?"], - buttons = { - { label = L["Reload Now"], onClick = function() ReloadUI() end }, - { label = L["Reload Later"] }, - }, - }) - end - - -- ===== INFO BANNER (global settings notice) ===== - do - local banner = GUI:CreateInfoBanner(self.child, { - tone = "info", - text = L["Settings on this page apply globally — changes persist across both the Party and Raid sections."], - }) - Add(banner, banner.layoutHeight, "both") - end - - -- ===== FRAME MODES GROUP (Column 1, Top) ===== - local modesGroup = GUI:CreateSettingsGroup(self.child, 280) - modesGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Modes"]), 40) - modesGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Party Frames"], DF.db, "partyEnabled", function() PromptReloadAfterModeToggle("party") end), 30) - modesGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Raid Frames"], DF.db, "raidEnabled", function() PromptReloadAfterModeToggle("raid") end), 30) - modesGroup:AddWidget(GUI:CreateLabel(self.child, - L["Completely enable or disable the Party or Raid frame system. Disabled modes are never created, consuming zero performance in the background. Requires a UI reload to apply."], - 260), 80) - Add(modesGroup, nil, 1) - - -- ===== BLIZZARD FRAMES GROUP (Column 1, Bottom) ===== - -- Storage stays per-mode (party + raid both updated via setter sync) - -- so AutoProfiles and ExportCategories continue to work unchanged. - local blizzardGroup = GUI:CreateSettingsGroup(self.child, 280) - blizzardGroup:AddWidget(GUI:CreateHeader(self.child, L["Blizzard Frames"]), 40) - - local disablePartyCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( - self.child, L["Disable Blizzard Party Frames"], - DF.db.party, "hideBlizzardPartyFrames", - function() PromptReloadBlizzard() end, - makeBlizGet("hideBlizzardPartyFrames"), - makeBlizSet("hideBlizzardPartyFrames", function() DF:UpdateBlizzardFrameVisibility() end) - ), 30) - disablePartyCheck.tooltip = L["Hides and unregisters all events on the default Blizzard party frames so they consume no performance."] - - local disableRaidCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( - self.child, L["Disable Blizzard Raid Frames"], - DF.db.party, "hideBlizzardRaidFrames", - function() PromptReloadBlizzard() end, - makeBlizGet("hideBlizzardRaidFrames"), - makeBlizSet("hideBlizzardRaidFrames", function() DF:UpdateBlizzardFrameVisibility() end) - ), 30) - disableRaidCheck.tooltip = L["Hides and unregisters all events on the default Blizzard raid frames so they consume no performance."] - - local disablePlayerCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( - self.child, L["Hide Blizzard Player Frame"], - nil, nil, - function() DF:UpdateDefaultPlayerFrame() end, - makeBlizGet("hideDefaultPlayerFrame"), - makeBlizSet("hideDefaultPlayerFrame", function() DF:UpdateDefaultPlayerFrame() end), - "hideDefaultPlayerFrame" - ), 30) - disablePlayerCheck.tooltip = L["Hides the default Blizzard player portrait and health bar."] - - -- Visual divider + small caption to separate the related sub-option - -- (Show Side Menu only applies once a Blizzard frame is disabled) - local divider = CreateFrame("Frame", nil, self.child) - divider:SetSize(260, 1) - local dividerTex = divider:CreateTexture(nil, "OVERLAY") - dividerTex:SetColorTexture(1, 1, 1, 0.08) - dividerTex:SetPoint("LEFT", 0, 0) - dividerTex:SetPoint("RIGHT", 0, 0) - dividerTex:SetHeight(1) - blizzardGroup:AddWidget(divider, 14) - - local sideMenuCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( - self.child, L["Show Party/Raid Side Menu"], - DF.db.party, "showBlizzardSideMenu", - function() PromptReloadBlizzard() end, - makeBlizGet("showBlizzardSideMenu"), - makeBlizSet("showBlizzardSideMenu", function() DF:UpdateBlizzardFrameVisibility() end) - ), 30) - sideMenuCheck.disableOn = function() - local p = DF.db.party - return not (p and (p.hideBlizzardPartyFrames or p.hideBlizzardRaidFrames)) - end - sideMenuCheck.tooltip = L["Shows the ping wheel & party management menu when Blizzard frames are disabled."] - - Add(blizzardGroup, nil, 1) - - -- ===== MINIMAP GROUP (Column 1) ===== - -- The minimap button is a single global UI element (no mode), so it lives - -- here rather than the per-mode Visibility page. Reads party-canonical and - -- writes both dbs so it stays consistent regardless of selected mode. - local minimapGroup = GUI:CreateSettingsGroup(self.child, 280) - minimapGroup:AddWidget(GUI:CreateHeader(self.child, L["Minimap"]), 40) - minimapGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Minimap Button"], nil, nil, function() - DF:UpdateMinimapButton() - end, makeBlizGet("showMinimapButton"), makeBlizSet("showMinimapButton"), "showMinimapButton"), 30) - Add(minimapGroup, nil, 1) - - -- ===== RENDERING GROUP (Column 1) ===== - -- Pixel-Perfect Scaling is a render-quality flag read by every frame and - -- element in BOTH modes (Frames/Core.lua GetPixelScale + 60-odd db.pixelPerfect - -- reads), so it's global — read party-canonical, write both mode dbs (same - -- pattern as the Blizzard/Minimap toggles above) — and lives here rather than - -- on the per-mode Frame page. - local function refreshPixelPerfect() - -- Re-apply header sizing + refresh the live frames (UpdateAllFrames auto- - -- routes party vs raid by the real in-world context) plus any test frames. - if DF.headersInitialized and DF.ApplyHeaderSettings then DF:ApplyHeaderSettings() end - if DF.UpdateAllFrames then DF:UpdateAllFrames() end - if (DF.testMode or DF.raidTestMode) and DF.RefreshTestFramesWithLayout then - DF:RefreshTestFramesWithLayout() - end - end - local renderingGroup = GUI:CreateSettingsGroup(self.child, 280) - renderingGroup:AddWidget(GUI:CreateHeader(self.child, L["Rendering"]), 40) - renderingGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Pixel-Perfect Scaling"], - nil, nil, refreshPixelPerfect, - makeBlizGet("pixelPerfect"), makeBlizSet("pixelPerfect"), "pixelPerfect"), 30) - -- Label slots below are sized for the WRAPPED text plus a gap. Labels are - -- variable-height widgets (GUI.RowHeight only governs fixed ones), so the - -- slot is whatever is passed here — too small and the next widget's label - -- sits on the last line of this one. - renderingGroup:AddWidget(GUI:CreateLabel(self.child, - L["Snaps sizes and borders to exact pixels for crisp rendering."], 250), 42) - -- Pixel-perfect scale hint: at a UI Scale of 768/physicalHeight, one UI unit - -- equals one physical pixel, so snapping has nothing to round away and borders - -- are at their crispest. Tell the user that value (and whether they're already - -- there) — purely informational, we never change their scale for them. - do - local function computeScaleHint() - local _, physH = GetPhysicalScreenSize() - local recScale = (physH and physH > 0) and (768 / physH) or 1 - local pp = (DF.GetPixelScale and DF:GetPixelScale()) or 1 - if math.abs(pp - 1) < 0.01 then - return L["Your UI Scale is already pixel-perfect for this resolution."] - end - return string.format( - L["Tip: for the crispest result at this resolution, set your UI Scale to %.4f — type /console UIScale %.4f to apply it (it may be below the in-game slider's minimum)."], - recScale, recScale) - end - local scaleHint = GUI:CreateLabel(self.child, computeScaleHint(), 250) - -- Recompute on page refresh so the hint isn't stale after a resolution or - -- UI-scale change (GetPixelScale is re-cached on those events). Idempotent - -- SetText — only writes when the text actually changed — so no relayout loop. - scaleHint.refreshContent = function() - local t = computeScaleHint() - if t ~= scaleHint._dfLastHint then - scaleHint._dfLastHint = t - scaleHint:SetText(t) - end - end - -- 3 wrapped lines (~48px) + a clear gap before the dropdown below. - renderingGroup:AddWidget(scaleHint, 72) - end - -- Aura duration-text update rate (account-wide, DF.GlobalDefaults). Feeds the - -- native duration binding at bind time (creation-frozen), so a change is - -- structural: invalidate the memoized value + re-drive rows AND the Aura - -- Designer (the ApplyColorByTime pattern — the other global folded into the - -- aura struct sigs). - local auraDurRateValues = { - SMOOTH = L["Smooth"], NORMAL = L["Normal"], PERFORMANCE = L["Performance"], - _order = { "SMOOTH", "NORMAL", "PERFORMANCE" }, - } - renderingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Aura Duration Update Rate"], - auraDurRateValues, DF:GetGlobalDB(), "auraDurationUpdateInterval", function() - if DF.InvalidateAuraDurationUpdateInterval then DF:InvalidateAuraDurationUpdateInterval() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - if DF.UpdateAllFrames then DF:UpdateAllFrames() end - if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end), 55) - renderingGroup:AddWidget(GUI:CreateLabel(self.child, - L["How often aura countdown text refreshes. Smooth updates ten times a second, Performance once a second. Normal keeps the standard rate."], - 250), 52) - Add(renderingGroup, nil, 1) - - -- ===== SETTINGS PANEL APPEARANCE GROUP (Column 2, Top) ===== - -- Controls the look of this settings panel itself — does NOT affect - -- in-game frame text (use Health Text / Name Text pages for those). - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings Panel Appearance"]), 40) - appearanceGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Settings Font"], DF.db, "settingsFont", function() - if GUI.RefreshSettingsFont then GUI:RefreshSettingsFont() end - end), 55) - appearanceGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Settings Font Outline"], DF.db, "settingsFontOutline", function() - if GUI.RefreshSettingsFont then GUI:RefreshSettingsFont() end - end), 55) - appearanceGroup:AddWidget(GUI:CreateLabel(self.child, - L["Font used for this settings panel. Does not affect in-game frame text — use the Text Designer for those."], - 260), 60) - Add(appearanceGroup, nil, 2) - - -- ===== LANGUAGE GROUP (Column 2, Bottom) ===== - local languageValues = { - AUTO = L["Auto (use client language)"], - enUS = "English", - deDE = "Deutsch", - esES = "Español (ES)", - esMX = "Español (MX)", - frFR = "Français", - itIT = "Italiano", - koKR = "한국어", - ptBR = "Português (BR)", - ruRU = "Русский", - zhCN = "中文 (简体)", - zhTW = "中文 (繁體)", - } - local languageGroup = GUI:CreateSettingsGroup(self.child, 280) - languageGroup:AddWidget(GUI:CreateHeader(self.child, L["Language"]), 40) - -- Language override lives on the per-character SavedVariable so - -- locale files can read it at file-load time (before DF.db exists). - languageGroup:AddWidget(GUI:CreateDropdown(self.child, L["Addon Language"], languageValues, DandersFramesCharDB, "languageOverride", function() - if DF.ShowPopupAlert then - DF:ShowPopupAlert({ - title = L["Reload Required"], - message = L["Changing the addon language requires a UI reload to take effect.\n\nReload now?"], - buttons = { - { label = L["Reload Now"], onClick = function() ReloadUI() end }, - { label = L["Later"] }, - }, - }) - end - end), 55) - languageGroup:AddWidget(GUI:CreateLabel(self.child, - L["Override the addon's display language. Auto follows your WoW client language. Translations are community-contributed and may be incomplete."], - 260), 60) - Add(languageGroup, nil, 2) - - -- ===== NOTIFICATIONS GROUP (Column 2, Bottom) ===== - local notificationsGroup = GUI:CreateSettingsGroup(self.child, 280) - notificationsGroup:AddWidget(GUI:CreateHeader(self.child, L["Notifications"]), 40) - notificationsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Notify me when a newer version is available"], - DF:GetGlobalDB(), "notifyOutdated", function() - -- Setting applies immediately; no extra callback needed. - end), 30) - Add(notificationsGroup, nil, 2) - end) - - -- General > Frame - local pageFrame = CreateSubTab("general", "general_frame", L["Frame"]) - BuildPage(pageFrame, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - -- "background"/"missingHealth" belong to bars_health (which hosts all - -- controls for those keys) — not registered here so its Copy/Sync/Reset - -- solely owns them. - -- "permanentMover" (16 keys) and the two growth keys were reached by nothing: - -- the whole permanent mover was skipped by Copy, Sync and Reset. ("border" and - -- "anchor" match nothing either — the real keys are frameBorder* / frameAnchor*, - -- already covered by "frame"; left in place as harmless intent.) - -- - -- ⚠ DELIBERATELY NOT LISTED: the 14 raid* layout keys (raidUseGroups, - -- raidPlayersPerRow, raidGroup*, raidFlat*, raidRowColSpacing...). They are - -- per-mode and so DO exist on the party side, but only the raid page ever - -- edits them — so party's copies sit at their untouched defaults. One list - -- drives Copy, Sync AND Reset, so owning them would make "Copy to Raid" from - -- the party page overwrite the user's raid layout with those defaults. The - -- cost of leaving them out is that Reset Page does not clear raid layout; - -- that is the lesser of the two, and fixing it properly needs per-direction - -- ownership, which SectionOwnsKey does not currently express. - Add(CreateCopyButton(self.child, {"frame", "permanentMover", "growDirection", "growthAnchor", "border", "anchor"}, L["Frame"], "general_frame"), 25, 2) - - -- Migration: Ensure new flat raid settings have defaults - if db.raidFlatGrowthAnchor == nil then db.raidFlatGrowthAnchor = "START" end - if db.raidFlatFrameAnchor == nil then db.raidFlatFrameAnchor = "START" end - if db.raidFlatColumnAnchor == nil then db.raidFlatColumnAnchor = "START" end - - -- Function to update the correct frames based on mode - local function UpdateFrames() - -- Invalidate the raid layoutSig optimization cache BEFORE - -- ApplyHeaderSettings runs, so this cycle's ApplyRaidGroupSorting - -- applies layout settings that aren't tracked by layoutSig (notably - -- raidGroupRowGrowth) instead of bailing. (PR #134) - if GUI.SelectedMode == "raid" then - DF._lastRaidLayoutSig = nil - DF._raidSortApplied = false - end - if DF.headersInitialized then - DF:ApplyHeaderSettings() - end - if GUI.SelectedMode == "raid" then - DF:UpdateRaidLayout() - if DF.SecureSort and DF.SecureSort.raidFramesRegistered then - DF.SecureSort:PushRaidLayoutConfig() - DF.SecureSort:PushRaidGroupLayoutConfig() - DF.SecureSort:TriggerSecureRaidSort() - end - -- Update test mode frames if active - if DF.raidTestMode then DF:UpdateRaidTestFrames() end - else - DF:UpdateAllFrames() - end - end - - -- Store references to sliders so we can update their labels - local groupsPerRowSlider, rowColSpacingSlider, playersPerRowSlider - - -- Function to update dynamic labels based on growth direction - local function UpdateDynamicLabels() - if groupsPerRowSlider and groupsPerRowSlider.label then - groupsPerRowSlider.label:SetText(db.growDirection == "VERTICAL" and L["Groups Per Column"] or L["Groups Per Row"]) - end - if rowColSpacingSlider and rowColSpacingSlider.label then - rowColSpacingSlider.label:SetText(db.growDirection == "VERTICAL" and L["Column Spacing"] or L["Row Spacing"]) - end - if playersPerRowSlider and playersPerRowSlider.label then - playersPerRowSlider.label:SetText(db.growDirection == "VERTICAL" and L["Players Per Column"] or L["Players Per Row"]) - end - end - - -- Custom callback for growth direction - local function OnGrowthDirectionChanged() - UpdateDynamicLabels() - UpdateFrames() - if GUI.SelectedMode == "raid" and not db.raidUseGroups and not InCombatLockdown() then - C_Timer.After(0, function() - if not InCombatLockdown() then - if DF.headersInitialized then DF:ApplyHeaderSettings() end - if DF.UpdateRaidLayout then DF:UpdateRaidLayout() end - end - end) - end - -- Defer label repositioning so headers have settled into new direction first - C_Timer.After(0, function() - if DF.UpdateRaidGroupLabels then - DF:UpdateRaidGroupLabels() - end - end) - -- Rebuild the page so orientation-dependent dropdown TITLES refresh - -- live (e.g. "Columns Grow From" vs "Rows Grow From") without needing - -- to reopen the settings window. Dropdowns bake their label at build - -- time, so a rebuild is the only way to update it. (Option VALUES are - -- now the static "Start (Left/Top)" / "End (Right/Bottom)" form, so - -- only the titles need refreshing.) Deferred so it runs after the - -- triggering dropdown's own click handler has finished unwinding. - C_Timer.After(0, function() - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end) - end - - -- Column 1 is the layout chain -- size, direction, raid mode, and - -- whichever group detail that mode implies. Column 2 keeps - -- Appearance at the top, where styling sits on every other page. - -- - -- Six boxes against three is not the imbalance it looks: FIVE of - -- the left column's boxes are raid-only, so in party mode the page - -- is Frame Size + Layout Direction against Appearance + Permanent - -- Mover. Permanent Mover is also by far the biggest box here, which - -- carries column 2 in raid. - -- ===== FRAME SIZE GROUP (Column 1) ===== - local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) - sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Size"]), 40) - sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Width"], 60, 300, 1, db, "frameWidth", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) - sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Height"], 20, 300, 1, db, "frameHeight", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) - sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Padding"], 0, 10, 1, db, "framePadding", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) - sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Scale"], 0.5, 2.0, 0.05, db, "frameScale", function() DF:UpdateContainerPosition() DF:UpdateRaidContainerPosition() UpdateFrames() end, function() DF:LightweightUpdateFrameScale() end, true), 55) - local frameSpacingSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Spacing"], -5, 50, 1, db, "frameSpacing", UpdateFrames, function() DF:LightweightUpdateFrameSpacing() end, true), 55) - frameSpacingSlider.hideOn = function() return GUI.SelectedMode == "raid" and not db.raidUseGroups end - Add(sizeGroup, nil, 1) - - -- ===== APPEARANCE GROUP (Column 2) ===== - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - -- Canonical border controls via the unified helper. Replaces the - -- previous hand-rolled Show / Color / Style / Texture / Size block. - -- classColor + roleColor are now first-class helper include flags (no - -- bespoke "Use Class Color" extra needed). (Pixel-Perfect Scaling moved - -- to General > Settings > Rendering — it's a global, mode-agnostic flag.) - GUI:CreateBorderControls(appearanceGroup, db, "frame", { - parent = self.child, - include = { - -- Frame Border is the outer chrome of the unit. It's a - -- structural element, not an alert surface, so animations - -- don't fit the design — removed in Stage 4.0 after Stage - -- 3 used it as a dev playground. - inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, - classColor = true, roleColor = true, - alpha = true, - }, - fullUpdate = function() UpdateFrames() DF:LightweightUpdateBorder() end, - lightUpdate = function() DF:LightweightUpdateBorder() end, - lightColors = function() DF:LightweightUpdateBorderColor() end, - refreshStates = function() if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end end, - sizeMin = 1, sizeMax = 16, sizeStep = 1, - }) - Add(appearanceGroup, nil, 2) - - -- ===== LAYOUT DIRECTION GROUP (Column 1) ===== - local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) - layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout Direction"]), 40) - - -- Party dropdown - local partyGrowOptions = { HORIZONTAL= L["Rows"], VERTICAL= L["Columns"] } - local partyArrangeDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], partyGrowOptions, db, "growDirection", OnGrowthDirectionChanged), 55) - partyArrangeDropdown.hideOn = function() return GUI.SelectedMode == "raid" end - - -- Raid GROUP dropdown (groups mode) - local raidGrowOptions = { HORIZONTAL= L["Columns"], VERTICAL= L["Rows"] } - local raidArrangeDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], raidGrowOptions, db, "growDirection", OnGrowthDirectionChanged), 55) - raidArrangeDropdown.hideOn = function() return GUI.SelectedMode ~= "raid" or not db.raidUseGroups end - - -- Raid FLAT dropdown - local flatGrowOptions = { HORIZONTAL= L["Rows"], VERTICAL= L["Columns"] } - local flatArrangeDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], flatGrowOptions, db, "growDirection", OnGrowthDirectionChanged), 55) - flatArrangeDropdown.hideOn = function() return GUI.SelectedMode ~= "raid" or db.raidUseGroups end - - -- Growth anchor (party only) - local anchorOptions = { START= L["Start (Left/Top)"], CENTER= L["Center"], END= L["End (Right/Bottom)"] } - local anchorDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Frames Grow From"], anchorOptions, db, "growthAnchor", UpdateFrames), 55) - anchorDropdown.hideOn = function() return GUI.SelectedMode == "raid" end - - Add(layoutGroup, nil, 1) - - -- ===== RAID LAYOUT MODE GROUP (Column 1, raid only) ===== - local raidModeGroup = GUI:CreateSettingsGroup(self.child, 280) - raidModeGroup:AddWidget(GUI:CreateHeader(self.child, L["Raid Layout Mode"]), 40) - raidModeGroup.hideOn = function() return GUI.SelectedMode ~= "raid" end - - raidModeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Group-Based Layout"], db, "raidUseGroups", function() - UpdateFrames() - if DF.SecureSort then - DF.SecureSort:PushRaidGroupLayoutConfig() - DF.SecureSort:TriggerSecureRaidSort() - end - -- Branch on the SETTING only. Folding `not InCombatLockdown()` into this - -- test meant that switching TO flat mode while in combat fell into the - -- else branch and disabled flat mode outright -- the opposite of what - -- the user asked for. SetEnabled already defers correctly in combat - -- (it queues pendingVisibility and replays at PLAYER_REGEN_ENABLED), so - -- just tell it the truth and let it schedule. - if not db.raidUseGroups then - if not InCombatLockdown() and DF.UpdateRaidGroupLabels then DF:UpdateRaidGroupLabels() end - C_Timer.After(0, function() - if DF.FlatRaidFrames then - if not DF.FlatRaidFrames.initialized and not InCombatLockdown() then - DF.FlatRaidFrames:Initialize() - end - if DF.FlatRaidFrames.initialized then DF.FlatRaidFrames:SetEnabled(true) end - end - if not InCombatLockdown() then - if DF.headersInitialized then DF:ApplyHeaderSettings() end - if DF.UpdateRaidLayout then DF:UpdateRaidLayout() end - end - end) - else - if DF.FlatRaidFrames and DF.FlatRaidFrames.initialized then - DF.FlatRaidFrames:SetEnabled(false) - end - end - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end), 30) - - raidModeGroup:AddWidget(GUI:CreateLabel(self.child, L["Enabled: Players organized by raid groups (1-8).\nDisabled: All players in one flat grid."], 250), 45) - Add(raidModeGroup, nil, 1) - - -- ===== GROUP LAYOUT SETTINGS (Column 1, raid+groups only) ===== - local groupLayoutGroup = GUI:CreateSettingsGroup(self.child, 280) - groupLayoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Layout Settings"]), 40) - groupLayoutGroup.hideOn = function() return GUI.SelectedMode ~= "raid" or not db.raidUseGroups end - - local groupLayoutHint = db.growDirection == "VERTICAL" and L["Players stack horizontally, groups grow top-to-bottom."] or L["Players stack vertically, groups grow left-to-right."] - groupLayoutGroup:AddWidget(GUI:CreateLabel(self.child, groupLayoutHint, 250), 25) - - groupLayoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Group Spacing"], -5, 100, 1, db, "raidGroupSpacing", UpdateFrames, function() DF:LightweightUpdateRaidLayout() end, true), 55) - - local rowColLabel = db.growDirection == "VERTICAL" and L["Column Spacing"] or L["Row Spacing"] - rowColSpacingSlider = groupLayoutGroup:AddWidget(GUI:CreateSlider(self.child, rowColLabel, -5, 100, 1, db, "raidRowColSpacing", UpdateFrames, function() DF:LightweightUpdateRaidLayout() end, true), 55) - - local groupsLabel = db.growDirection == "VERTICAL" and L["Groups Per Column"] or L["Groups Per Row"] - groupsPerRowSlider = groupLayoutGroup:AddWidget(GUI:CreateSlider(self.child, groupsLabel, 1, 8, 1, db, "raidGroupsPerRow", UpdateFrames, function() DF:LightweightUpdateRaidLayout() end, true), 55) - - groupLayoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Group Alignment"], anchorOptions, db, "raidGroupAnchor", UpdateFrames), 55) - - local rowGrowLabel = db.growDirection == "VERTICAL" and L["Columns Grow From"] or L["Rows Grow From"] - local rowGrowOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } - groupLayoutGroup:AddWidget(GUI:CreateDropdown(self.child, rowGrowLabel, rowGrowOptions, db, "raidGroupRowGrowth", UpdateFrames), 55) - - -- Players Grow From = the direction players fill the group's main axis. - -- HORIZONTAL groups stack players vertically (Top/Bottom); VERTICAL groups - -- stack players horizontally (Left/Right). Values map to START/END. - local playerAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } - groupLayoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Players Grow From"], playerAnchorOptions, db, "raidPlayerAnchor", UpdateFrames), 55) - - Add(groupLayoutGroup, nil, 1) - - -- ===== GROUP VISIBILITY (Column 1, raid only) ===== - local groupVisGroup = GUI:CreateSettingsGroup(self.child, 280) - groupVisGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Visibility"]), 40) - groupVisGroup.hideOn = function() return GUI.SelectedMode ~= "raid" end - - groupVisGroup:AddWidget(GUI:CreateLabel(self.child, L["Choose which groups to display."], 250), 25) - - -- Initialize raidGroupVisible if it doesn't exist - if not db.raidGroupVisible then - db.raidGroupVisible = {[1]=true,[2]=true,[3]=true,[4]=true,[5]=true,[6]=true,[7]=true,[8]=true} - end - - for i = 1, 8 do - local groupIndex = i - local overrideKey = "raidGroupVisible_" .. i - groupVisGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Group"] .. " " .. i, nil, nil, - function() - if db.raidUseGroups then - -- Separated mode - DF:UpdateRaidHeaderVisibility(); DF:PositionRaidHeaders() - else - -- Flat mode - rebuild groupFilter and nameList - if DF.FlatRaidFrames then - DF.FlatRaidFrames:UpdateContainerSize() - DF.FlatRaidFrames:UpdateSorting() - end - end - UpdateFrames() - end, - function() return db.raidGroupVisible[groupIndex] ~= false end, - function(val) db.raidGroupVisible[groupIndex] = val end, - overrideKey - ), 25) - end - - Add(groupVisGroup, nil, 1) - - -- ===== GROUP DISPLAY ORDER (Column 2, raid+groups only) ===== - local groupOrderGroup = GUI:CreateSettingsGroup(self.child, 280) - groupOrderGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Display Order"]), 40) - groupOrderGroup.hideOn = function() return GUI.SelectedMode ~= "raid" or not db.raidUseGroups end - - groupOrderGroup:AddWidget(GUI:CreateLabel(self.child, L["Drag to reorder groups. Top = first."], 250), 25) - - local playerGroupFirstCheck = groupOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["My Group First"], db, "raidPlayerGroupFirst", function() - if DF.UpdatePlayerGroupTracking then DF:UpdatePlayerGroupTracking() end - if DF.UpdateRaidGroupOrderAttributes then DF:UpdateRaidGroupOrderAttributes() end - DF:TriggerRaidPosition() - UpdateFrames() - end), 25) - playerGroupFirstCheck.tooltip = L["When enabled, the group you are in will always be displayed first."] - - -- Initialize raidGroupDisplayOrder if it doesn't exist - if not db.raidGroupDisplayOrder then - db.raidGroupDisplayOrder = {1, 2, 3, 4, 5, 6, 7, 8} - end - - local groupOrderWidget = GUI:CreateGroupOrderList(self.child, db, "raidGroupDisplayOrder", function() - if DF.UpdateRaidGroupOrderAttributes then DF:UpdateRaidGroupOrderAttributes() end - DF:TriggerRaidPosition() - UpdateFrames() - end) - groupOrderGroup:AddWidget(groupOrderWidget, 230) - - Add(groupOrderGroup, nil, 2) - - -- ===== FLAT GRID SETTINGS (Column 1, raid+flat only) ===== - local flatGridGroup = GUI:CreateSettingsGroup(self.child, 280) - flatGridGroup:AddWidget(GUI:CreateHeader(self.child, L["Flat Grid Settings"]), 40) - flatGridGroup.hideOn = function() return GUI.SelectedMode ~= "raid" or db.raidUseGroups end - - flatGridGroup:AddWidget(GUI:CreateLabel(self.child, L["All players in a unified grid. Sorting applies raid-wide."], 250), 25) - - local function UpdateFlatLayoutFull() - if InCombatLockdown() then return end - if DF.headersInitialized then DF:ApplyHeaderSettings() end - if GUI.SelectedMode == "raid" then DF:UpdateRaidLayout() end - end - - local playersPerLabel = db.growDirection == "VERTICAL" and L["Players Per Column"] or L["Players Per Row"] - playersPerRowSlider = flatGridGroup:AddWidget(GUI:CreateSlider(self.child, playersPerLabel, 1, 40, 1, db, "raidPlayersPerRow", UpdateFlatLayoutFull, UpdateFlatLayoutFull, true), 55) - - local growthAnchorOptions = { START= L["Start (Left/Top)"], CENTER= L["Center"], END= L["End (Right/Bottom)"] } - flatGridGroup:AddWidget(GUI:CreateDropdown(self.child, L["Grid Alignment"], growthAnchorOptions, db, "raidFlatGrowthAnchor", UpdateFrames), 55) - - -- Columns/Rows Grow From = the direction the grid wraps (secondary axis). - -- VERTICAL (Columns) wraps left/right; HORIZONTAL (Rows) wraps top/bottom. - local flatColumnLabel = db.growDirection == "VERTICAL" and L["Columns Grow From"] or L["Rows Grow From"] - local flatColumnOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } - flatGridGroup:AddWidget(GUI:CreateDropdown(self.child, flatColumnLabel, flatColumnOptions, db, "raidFlatColumnAnchor", UpdateFrames), 55) - - -- Players Grow From = the direction players fill the grid's main axis. - -- HORIZONTAL (Rows) fills Left/Right; VERTICAL (Columns) fills Top/Bottom. - -- Replaces the old "Reverse Order" checkbox; START/END values are identical. - local flatFillOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } - flatGridGroup:AddWidget(GUI:CreateDropdown(self.child, L["Players Grow From"], flatFillOptions, db, "raidFlatFrameAnchor", UpdateFrames), 55) - - flatGridGroup:AddWidget(GUI:CreateSlider(self.child, L["Horizontal Spacing"], -5, 100, 1, db, "raidFlatHorizontalSpacing", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) - flatGridGroup:AddWidget(GUI:CreateSlider(self.child, L["Vertical Spacing"], -5, 100, 1, db, "raidFlatVerticalSpacing", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) - - Add(flatGridGroup, nil, 1) - - -- Update labels on show - if groupsPerRowSlider and groupsPerRowSlider.label then - groupsPerRowSlider:HookScript("OnShow", UpdateDynamicLabels) - end - if rowColSpacingSlider and rowColSpacingSlider.label then - rowColSpacingSlider:HookScript("OnShow", UpdateDynamicLabels) - end - if playersPerRowSlider and playersPerRowSlider.label then - playersPerRowSlider:HookScript("OnShow", UpdateDynamicLabels) - end - - -- ===== PERMANENT MOVER GROUP (Column 2) ===== - local permMoverGroup = GUI:CreateSettingsGroup(self.child, 280) - permMoverGroup:AddWidget(GUI:CreateHeader(self.child, L["Permanent Mover"]), 40) - - permMoverGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Permanent Mover"], db, "permanentMover", function() - DF:UpdatePermanentMoverVisibility() - end), 30) - - local moverAnchorValues = { - TOPLEFT= L["Top Left"], TOP= L["Top"], TOPRIGHT= L["Top Right"], - LEFT= L["Left"], RIGHT= L["Right"], - BOTTOMLEFT= L["Bottom Left"], BOTTOM= L["Bottom"], BOTTOMRIGHT= L["Bottom Right"], - } - local permMoverAnchor = permMoverGroup:AddWidget( - GUI:CreateDropdown(self.child, L["Handle Position"], moverAnchorValues, db, "permanentMoverAnchor", function() - DF:UpdatePermanentMoverAnchor(GUI.SelectedMode) - end), 55) - permMoverAnchor.disableOn = function(d) return not d.permanentMover end - - local attachValues = { CONTAINER= L["Container"], FIRST= L["First Unit"], LAST= L["Last Unit"] } - local permAttach = permMoverGroup:AddWidget( - GUI:CreateDropdown(self.child, L["Attach To"], attachValues, db, "permanentMoverAttachTo", function() - DF:UpdatePermanentMoverAnchor(GUI.SelectedMode) - end), 55) - permAttach.disableOn = function(d) return not d.permanentMover end - permAttach.tooltip = L["Attach the handle to the container, the first visible unit, or the last visible unit."] - - local function PermMoverAnchorUpdate() DF:UpdatePermanentMoverAnchor(GUI.SelectedMode) end - local function PermMoverSizeUpdate() DF:UpdatePermanentMoverSize(GUI.SelectedMode) end - - local permOffsetX = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "permanentMoverOffsetX", PermMoverAnchorUpdate, PermMoverAnchorUpdate), 55) - permOffsetX.disableOn = function(d) return not d.permanentMover end - - local permOffsetY = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "permanentMoverOffsetY", PermMoverAnchorUpdate, PermMoverAnchorUpdate), 55) - permOffsetY.disableOn = function(d) return not d.permanentMover end - - local permWidth = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Handle Width"], 5, 500, 1, db, "permanentMoverWidth", PermMoverSizeUpdate, PermMoverSizeUpdate), 55) - permWidth.disableOn = function(d) return not d.permanentMover end - - local permHeight = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Handle Height"], 5, 500, 1, db, "permanentMoverHeight", PermMoverSizeUpdate, PermMoverSizeUpdate), 55) - permHeight.disableOn = function(d) return not d.permanentMover end - - local permHover = permMoverGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show on Hover Only"], db, "permanentMoverShowOnHover", function() - DF:UpdatePermanentMoverVisibility() - end), 30) - permHover.disableOn = function(d) return not d.permanentMover end - permHover.tooltip = L["Handle is invisible until you hover over it. Fades in and out smoothly."] - - local permCombat = permMoverGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "permanentMoverHideInCombat", function() - DF:UpdatePermanentMoverCombatState() - end), 30) - permCombat.disableOn = function(d) return not d.permanentMover end - permCombat.tooltip = L["Hides the handle during combat. If disabled, the handle changes color to indicate it is locked."] - - local permColor = permMoverGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Handle Color"], db, "permanentMoverColor", false, function() - DF:UpdatePermanentMoverColor(GUI.SelectedMode) - end), 35) - permColor.disableOn = function(d) return not d.permanentMover end - - local permCombatColor = permMoverGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Combat Color"], db, "permanentMoverCombatColor", false, nil), 35) - permCombatColor.disableOn = function(d) return not d.permanentMover end - permCombatColor.tooltip = L["Color shown when in combat to indicate the handle is locked."] - - -- Quick action dropdowns - local actionValues = {} - for id, data in pairs(DF.PERM_MOVER_ACTIONS) do - actionValues[id] = data.label - end - - local permActionLeft = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Left Click"], actionValues, db, "permanentMoverActionLeft"), 55) - permActionLeft.disableOn = function(d) return not d.permanentMover end - - local permActionRight = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Right Click"], actionValues, db, "permanentMoverActionRight"), 55) - permActionRight.disableOn = function(d) return not d.permanentMover end - - local permActionShiftLeft = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Shift+Left Click"], actionValues, db, "permanentMoverActionShiftLeft"), 55) - permActionShiftLeft.disableOn = function(d) return not d.permanentMover end - - local permActionShiftRight = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Shift+Right Click"], actionValues, db, "permanentMoverActionShiftRight"), 55) - permActionShiftRight.disableOn = function(d) return not d.permanentMover end - - local permPullTimer = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Pull Timer Duration"], 3, 30, 1, db, "permanentMoverPullTimerDuration"), 55) - permPullTimer.disableOn = function(d) return not d.permanentMover end - permPullTimer.tooltip = L["Duration in seconds for the Pull Timer quick action."] - - Add(permMoverGroup, nil, 2) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "general_sorting", label = L["Sorting"]}, - {pageId = "bars_health", label = L["Health Bar"]}, - -- LEGACY-TEXT-CLEANUP: legacy text page hidden; link removed - -- {pageId = "text_name", label = L["Name Text"]}, - }), 30, "both") - end) - - -- General > Global Fonts - local pageGlobalFonts = CreateSubTab("general", "general_fonts", L["Global Fonts"]) - BuildPage(pageGlobalFonts, function(self, db, Add, AddSpace, AddSyncPoint) - Add(CreateCopyButton(self.child, {"fontShadow"}, L["Global Fonts"], "general_fonts"), 25, 2) - -- Initialize temp storage for selections (persists during session) - if not DF.GlobalFontTemp then - DF.GlobalFontTemp = { - font = db.nameFont or "Fonts\\FRIZQT__.TTF", - outline = db.nameTextOutline or "OUTLINE", - } - end - - -- ===== FONT SELECTION GROUP (Column 1) ===== - local fontSelectGroup = GUI:CreateSettingsGroup(self.child, 280) - fontSelectGroup:AddWidget(GUI:CreateHeader(self.child, L["Global Font Settings"]), 40) - fontSelectGroup:AddWidget(GUI:CreateLabel(self.child, L["Set a font and outline style, then click Apply to update ALL text elements."], 250), 40) - - fontSelectGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], DF.GlobalFontTemp, "font", function() end), 55) - - fontSelectGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], DF.GlobalFontTemp, "outline", function() end), 55) - fontSelectGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], DF.GlobalFontTemp, "outline", function() end), 30) - - -- Themed Apply button - local applyBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") - GUI:StyleButton(applyBtn, { width = 120, height = 28, text = L["Apply to All"] }) - applyBtn.text = applyBtn.Text - applyBtn:SetScript("OnClick", function() - local font = DF.GlobalFontTemp.font - local outline = DF.GlobalFontTemp.outline - - -- Clear font family cache so new fonts are created - if DF.ClearFontCache then DF:ClearFontCache() end - - -- Apply to all font settings - db.nameFont = font; db.nameTextOutline = outline - db.healthFont = font; db.healthTextOutline = outline - db.statusTextFont = font; db.statusTextOutline = outline - db.buffStackFont = font; db.buffStackOutline = outline - db.buffDurationFont = font; db.buffDurationOutline = outline - db.debuffStackFont = font; db.debuffStackOutline = outline - db.debuffDurationFont = font; db.debuffDurationOutline = outline - db.petNameFont = font; db.petNameFontOutline = outline - db.petHealthFont = font; db.petHealthFontOutline = outline - db.personalTargetedSpellDurationFont = font; db.personalTargetedSpellDurationOutline = outline - db.targetedListFont = font; db.targetedListFontOutline = outline - db.defensiveIconDurationFont = font; db.defensiveIconDurationOutline = outline - db.statusIconFont = font; db.statusIconFontOutline = outline - -- AFK timer text inherits the status-icon font; clear any per-timer - -- override so it follows the freshly-applied global font. - db.afkIconTimerFont = nil; db.afkIconTimerOutline = nil - if db.groupLabelFont ~= nil then - db.groupLabelFont = font; db.groupLabelOutline = outline - end - -- Aura Designer global defaults + clear per-instance overrides. - -- AD config now lives in the preset this mode uses, not inline. - -- BASE resolver: "apply font globally" edits the user's base - -- preset — with a runtime auto-layout active, the ACTIVE resolver - -- would mutate the layout's preset instead (editor model is BASE). - local _adMode = (db == DF.db.raid) and "raid" or "party" - local _adCfg = (DF.GetModeBaseAuraDesigner and DF:GetModeBaseAuraDesigner(_adMode)) - or (DF.GetModeAuraDesigner and DF:GetModeAuraDesigner(_adMode)) - if _adCfg then - if _adCfg.defaults then - local adDefaults = _adCfg.defaults - adDefaults.durationFont = font; adDefaults.durationOutline = outline - adDefaults.stackFont = font; adDefaults.stackOutline = outline - end - -- Clear per-instance font overrides so all indicators inherit global - if _adCfg.auras then - for _, auraCfg in pairs(_adCfg.auras) do - if auraCfg.indicators then - for _, inst in ipairs(auraCfg.indicators) do - inst.durationFont = nil; inst.durationOutline = nil - inst.stackFont = nil; inst.stackOutline = nil - end - end - end - end - end - - -- Text Designer text elements: the legacy name/health/status - -- fontstrings are retired (IsLegacyTextHidden), so the visible - -- name/health/status text now comes from the Text Designer. Drive - -- its elements too (BASE preset, matching the AD block above) so - -- "Apply to All" actually changes that text. - local _tdMode = (db == DF.db.raid) and "raid" or "party" - local _tdCfg = (DF.GetModeBaseTextDesigner and DF:GetModeBaseTextDesigner(_tdMode)) - or (DF.GetModeTextDesigner and DF:GetModeTextDesigner(_tdMode)) - if _tdCfg then - if _tdCfg.elements then - for _, el in ipairs(_tdCfg.elements) do - el.font = font; el.outline = outline - end - end - if _tdCfg.globalDefaults then - _tdCfg.globalDefaults.font = font - _tdCfg.globalDefaults.outline = outline - end - end - - DF:UpdateAllFrames() - if GUI.SelectedMode == "raid" and DF.UpdateRaidLayout then DF:UpdateRaidLayout() end - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - if (DF.testMode or DF.raidTestMode) and DF.UpdateAllTestTargetedSpell then DF:UpdateAllTestTargetedSpell() end - if DF.UpdateTestPersonalTargetedSpells then DF:UpdateTestPersonalTargetedSpells() end - if DF.UpdateTargetedListLayout then DF:UpdateTargetedListLayout() end - if DF.UpdateAllFramesStatusIcons then DF:UpdateAllFramesStatusIcons() end - - -- Refresh test frames to apply new fonts - if DF.RefreshTestFrames then DF:RefreshTestFrames() end - - -- Force Aura Designer to re-apply indicators with new fonts - if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - -- Also refresh the AD options preview if visible - if DF.AuraDesigner_RefreshPage then DF:AuraDesigner_RefreshPage() end - - -- Text Designer owns the live name/health/status text — re-render it. - if DF.TextDesigner and DF.TextDesigner.Preview and DF.TextDesigner.Preview.RefreshLiveFrames then - DF.TextDesigner.Preview:RefreshLiveFrames() - end - - DF:Say("Applied global font settings to all text elements.") - end) - fontSelectGroup:AddWidget(applyBtn, 35) - - fontSelectGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Crisp Font Rendering (SDF)"], DF.db, "fontSlug", function() - if DF.ClearFontCache then DF:ClearFontCache() end - DF:UpdateAllFrames() - if GUI.SelectedMode == "raid" and DF.UpdateRaidLayout then DF:UpdateRaidLayout() end - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - if DF.RefreshTestFrames then DF:RefreshTestFrames() end - end), 30) - fontSelectGroup:AddWidget(GUI:CreateLabel(self.child, L["Renders text with signed-distance-field smoothing for sharper edges at any size. Applies to None and Outline styles only (not Monochrome, Thick, or Shadow)."], 250), 50) - - Add(fontSelectGroup, nil, 1) - - -- ===== SHADOW SETTINGS GROUP (Column 1) ===== - local shadowGroup = GUI:CreateSettingsGroup(self.child, 280) - shadowGroup:AddWidget(GUI:CreateHeader(self.child, L["Shadow Settings"]), 40) - shadowGroup:AddWidget(GUI:CreateLabel(self.child, L["These settings apply when using 'Shadow' outline style. Use larger offsets for more dramatic shadows."], 250), 40) - - local function UpdateShadowSettings() - -- Full update on release - if DF.ClearFontCache then DF:ClearFontCache() end - DF:UpdateAllFrames() - if GUI.SelectedMode == "raid" and DF.UpdateRaidLayout then DF:UpdateRaidLayout() end - if DF.ApplyPetSettings then DF:ApplyPetSettings() end - -- UpdateAllFrames doesn't reach the pinned pool — re-font it too. - if DF.RefreshPinnedFonts then DF:RefreshPinnedFonts() end - -- Re-render the Text Designer overlay (the visible text) so its shadow - -- updates on pinned + live frames too. - if DF.TextDesigner and DF.TextDesigner.Preview and DF.TextDesigner.Preview.RefreshLiveFrames then - DF.TextDesigner.Preview:RefreshLiveFrames() - end - end - - local function LightweightShadowUpdate() - if DF.LightweightUpdateFontShadows then DF:LightweightUpdateFontShadows() end - end - - shadowGroup:AddWidget(GUI:CreateSlider(self.child, L["Shadow X Offset"], -10, 10, 0.5, db, "fontShadowOffsetX", UpdateShadowSettings, LightweightShadowUpdate), 50) - shadowGroup:AddWidget(GUI:CreateSlider(self.child, L["Shadow Y Offset"], -10, 10, 0.5, db, "fontShadowOffsetY", UpdateShadowSettings, LightweightShadowUpdate), 50) - shadowGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Shadow Color"], db, "fontShadowColor", true, UpdateShadowSettings, LightweightShadowUpdate, true), 40) - - Add(shadowGroup, nil, 1) - - -- ===== AFFECTED ELEMENTS GROUP (Column 2) ===== - local infoGroup = GUI:CreateSettingsGroup(self.child, 280) - infoGroup:AddWidget(GUI:CreateHeader(self.child, L["Affected Elements"]), 40) - infoGroup:AddWidget(GUI:CreateLabel(self.child, L["• Text Designer (Name, Health, Status & custom text)\n• Buff Stack & Duration\n• Debuff Stack & Duration\n• Pet Frame Text\n• Targeted Spell Duration\n• Defensive Icon Duration\n• All Icon Text (Res, Summon, etc.)\n• Group Labels (Raid)\n• Targeted List\n• Personal Targeted Spell\n• Aura Designer Indicators\n• Pinned Frames"], 250), 235) - infoGroup:AddWidget(GUI:CreateNote(self.child, L["Font sizes are not changed. Adjust sizes in each element's page."], {tone = "caution", prefix = "Note", width = 250}), 40) - Add(infoGroup, nil, 2) - end) - - -- General > Group Labels (Raid only, group-based layout only) - local pageGroupLabels = CreateSubTab("general", "general_labels", L["Group Labels"]) - BuildPage(pageGroupLabels, function(self, db, Add, AddSpace, AddSyncPoint) - Add(CreateCopyButton(self.child, {"groupLabel"}, L["Group Labels"], "general_labels"), 25, 2) - -- The dependent groups stay hidden under mode/variant gating (not raid, - -- or flat layout), but when visible they GREY OUT (disabled-in-place) - -- while the Enable toggle is off rather than vanishing. - local function HideGroupLabelOptions() - return GUI.SelectedMode ~= "raid" or not db.raidUseGroups - end - local function DisableGroupLabelOptions(d) - return not d.groupLabelEnabled - end - - local function UpdateLabels() - if DF.UpdateRaidGroupLabels then DF:UpdateRaidGroupLabels() end - end - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Raid Group Labels"]), 40) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Display labels above or beside each raid group."], 250), 25) - local groupLabelEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Group Labels"], db, "groupLabelEnabled", function() - UpdateLabels() - self:RefreshStates() - end), 30) - groupLabelEnable.keepEnabled = true - settingsGroup.hideOn = HideGroupLabelOptions - Add(settingsGroup, nil, 1) - - -- ===== TEXT FORMAT GROUP (Column 1) ===== - local formatGroup = GUI:CreateSettingsGroup(self.child, 280) - formatGroup:AddWidget(GUI:CreateHeader(self.child, L["Text Format"]), 40) - - local formatOptions = { - ["GROUP_NUM"] = L["Group 1"], - ["SHORT"] = L["G1"], - ["NUM_ONLY"] = L["1"], - ["ROMAN"] = L["I, II, III..."], - } - formatGroup:AddWidget(GUI:CreateDropdown(self.child, L["Label Format"], formatOptions, db, "groupLabelFormat", UpdateLabels), 55) - formatGroup.hideOn = HideGroupLabelOptions - formatGroup.disableChildrenOn = DisableGroupLabelOptions - Add(formatGroup, nil, 2) - - -- ===== FONT GROUP (Column 1) ===== - local fontGroup = GUI:CreateSettingsGroup(self.child, 280) - fontGroup:AddWidget(GUI:CreateHeader(self.child, L["Font Settings"]), 40) - fontGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "groupLabelFont", UpdateLabels), 55) - fontGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 8, 24, 1, db, "groupLabelFontSize", UpdateLabels, function() DF:LightweightUpdateGroupLabels() end, true), 55) - - fontGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "groupLabelOutline", UpdateLabels), 55) - fontGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "groupLabelOutline", UpdateLabels), 30) - fontGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Label Color"], db, "groupLabelColor", true, UpdateLabels, function() DF:LightweightUpdateGroupLabelColor() end, true), 35) - fontGroup.hideOn = HideGroupLabelOptions - fontGroup.disableChildrenOn = DisableGroupLabelOptions - Add(fontGroup, nil, 2) - - -- ===== POSITION GROUP (Column 2) ===== - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - - local positionOptions = { - ["START"] = L["Start of Group"], - ["CENTER"] = L["Center of Group"], - ["END"] = L["End of Group"], - } - positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Label Position"], positionOptions, db, "groupLabelPosition", UpdateLabels), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "groupLabelOffsetX", UpdateLabels, function() DF:LightweightUpdateGroupLabels() end, true), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "groupLabelOffsetY", UpdateLabels, function() DF:LightweightUpdateGroupLabels() end, true), 55) - positionGroup:AddWidget(GUI:CreateLabel(self.child, L["Start: Above/left of groups.\nCenter: Middle of the group.\nEnd: Below/right of groups."], 250), 50) - positionGroup.hideOn = HideGroupLabelOptions - positionGroup.disableChildrenOn = DisableGroupLabelOptions - Add(positionGroup, nil, 1) - - -- Party mode message - local partyMsg = Add(GUI:CreateLabel(self.child, L["Group labels are only available for raid frames.\n\nSwitch to Raid mode using the toggle at the top\nof the settings panel to configure group labels."], 400), 80, "both") - partyMsg.hideOn = function() return GUI.SelectedMode == "raid" end - - -- Flat mode message - local flatMsg = Add(GUI:CreateNote(self.child, L["Group labels are not available in Flat Grid layout.\n\nEnable 'Use Group-Based Layout' in Frame settings\nto use group labels."], {tone = "caution", width = 400}), 80, "both") - flatMsg.hideOn = function() return GUI.SelectedMode ~= "raid" or db.raidUseGroups end - end) - - -- General > Pinned Frames - local pagePinnedFrames = CreateSubTab("general", "general_pinnedframes", L["Pinned Frames"]) - BuildPage(pagePinnedFrames, function(self, db, Add, AddSpace, AddSyncPoint) - Add(CreateCopyButton(self.child, {"pinnedFrames"}, L["Pinned Frames"], "general_pinnedframes"), 25, 2) - -- Constants — mirror the runtime cap so the editor builds exactly as many - -- tab buttons as the backend allows (sets beyond the current count are hidden). - local HIGHLIGHT_MAX_SETS = (DF.PinnedFrames and DF.PinnedFrames.MAX_SETS) or 5 - - -- Initialize pinnedFrames in db if needed - if not db.pinnedFrames then - db.pinnedFrames = { - disableInPvP = true, -- mode-level: dormant in arena/battlegrounds - sets = { - [1] = { - enabled = false, name = "Pinned 1", players = {}, - growDirection = "HORIZONTAL", unitsPerRow = 5, - horizontalSpacing = 2, verticalSpacing = 2, scale = 1.0, - position = { point = "CENTER", x = 0, y = 200 }, - showLabel = false, - autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, - keepOfflinePlayers = false, - }, - [2] = { - enabled = false, name = "Pinned 2", players = {}, - growDirection = "HORIZONTAL", unitsPerRow = 5, - horizontalSpacing = 2, verticalSpacing = 2, scale = 1.0, - position = { point = "CENTER", x = 0, y = -200 }, - showLabel = false, - autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, - keepOfflinePlayers = false, - }, - }, - } - end - - -- Migration: mode-level disableInPvP (existing profiles predate it). nil is - -- treated as true by the runtime gate, but seed it so the toggle reads right. - if db.pinnedFrames.disableInPvP == nil then db.pinnedFrames.disableInPvP = true end - - -- Migration: add new options to existing sets - for i = 1, #db.pinnedFrames.sets do - local set = db.pinnedFrames.sets[i] - if set then - if set.autoAddTanks == nil then set.autoAddTanks = false end - if set.autoAddHealers == nil then set.autoAddHealers = false end - if set.autoAddDPS == nil then set.autoAddDPS = false end - -- Match Config's default (false). Post-fix, manual pins always - -- persist (CleanOfflinePlayers spares manualPlayers); this toggle - -- only keeps AUTO-added members after they go offline / leave. - if set.keepOfflinePlayers == nil then set.keepOfflinePlayers = false end - if set.columnAnchor == nil then set.columnAnchor = "START" end - if set.frameAnchor == nil then set.frameAnchor = "START" end - -- CENTER anchor was dropped (never truly centred the frames; it - -- rendered as START). Normalise so the dropdown has a valid value. - if set.columnAnchor == "CENTER" then set.columnAnchor = "START" end - if set.frameAnchor == "CENTER" then set.frameAnchor = "START" end - -- set.locked retired (global lock only); strip the dead field. - set.locked = nil - if set.showLabel == nil then set.showLabel = false end - if set.players == nil then set.players = {} end - if set.manualPlayers == nil then set.manualPlayers = {} end - if set.frameType == nil then set.frameType = "player" end - if set.testCount == nil then set.testCount = 3 end - end - end - - -- Current active tab (persist across page refreshes so switching tabs - -- between sets with different frameTypes — which calls RefreshCurrentPage — - -- doesn't snap back to tab 1) - pagePinnedFrames.persistedTab = pagePinnedFrames.persistedTab or 1 - -- Clamp into the live set count — a set may have been removed since this - -- was last persisted (or in the other mode), so never address a nil set. - local setCount = #db.pinnedFrames.sets - if pagePinnedFrames.persistedTab > setCount then pagePinnedFrames.persistedTab = setCount end - if pagePinnedFrames.persistedTab < 1 then pagePinnedFrames.persistedTab = 1 end - local activeHighlightTab = pagePinnedFrames.persistedTab - -- Sub-tab within a set's editor: - -- "setup" = Settings + Frame Type (always present) - -- "appearance" = Frame Style + Layout (always present) - -- "members" = Unit Selection + Auto-Populate (player sets only) - -- Persisted across page rebuilds; defaults to Setup. Clamped below so a - -- persisted "members" never sticks on a boss set (which has no Members tab). - pagePinnedFrames.persistedSubTab = pagePinnedFrames.persistedSubTab or "setup" - local activeSubTab = pagePinnedFrames.persistedSubTab - local tabButtons = {} - local controlsToRefresh = {} - -- Forward refs assigned in the tab-strip build below; RefreshTabs reads them. - local tabContainer, addSetBtn, setMeta - - -- Invalidate + rebuild the pinned page (after add/remove the whole editor - -- must re-render with the new tab count + the active set's widgets). - local function RebuildPinnedPage() - if GUI.InvalidatePage then GUI:InvalidatePage(GUI.CurrentPageName) end - if GUI.RefreshCurrentPage then GUI.RefreshCurrentPage() end - end - - local function DoAddSet() - if not DF.PinnedFrames then return end - -- Target the mode currently being edited (party/raid are independent). - local newIndex = DF.PinnedFrames:AddSet(GUI.SelectedMode) - if newIndex then - pagePinnedFrames.persistedTab = newIndex -- jump to the new set - RebuildPinnedPage() - end - end - - local function DoRemoveSet(idx) - if not DF.PinnedFrames then return end - -- Capture the edited mode at click time (robust if the GUI mode changes - -- while the confirm popup is open). Party/raid set lists are independent. - -- The closure replaces what used to travel as the StaticPopup `data` - -- payload, which is the field whose behaviour varies across clients. - local mode = GUI.SelectedMode - DF:ShowPopupAlert({ - title = L["Remove Pinned Set"], - message = L["Remove this pinned set? Its members and settings will be lost."], - buttons = { - { - label = L["Remove"], - onClick = function() - if DF.PinnedFrames:RemoveSet(idx, mode) then - if pagePinnedFrames.persistedTab > 1 then - pagePinnedFrames.persistedTab = pagePinnedFrames.persistedTab - 1 - end - RebuildPinnedPage() - end - end, - }, - { label = L["Cancel"] }, - }, - }) - end - - local function GetCurrentSet() - return db.pinnedFrames.sets[activeHighlightTab] - end - - local function IsCurrentBossMode() - local s = GetCurrentSet() - return s and s.frameType == "friendlyBoss" - end - - -- A pinned set that is not enabled shows ONLY its Enable toggle; everything - -- else (the other Setup controls, Frame Type, and the Appearance/Members - -- tabs) is hidden until the set is enabled. - local function PinnedSetDisabled() - local s = GetCurrentSet() - return not (s and s.enabled) - end - - -- Forward-declared: the set-tab OnClick (defined below, before this is - -- assigned) re-runs it when you switch sets so the new set's enabled state - -- re-drives the sub-tab visibility. - local RefreshSubTabs - - local function RefreshControls() - for _, ctrl in ipairs(controlsToRefresh) do - if ctrl.Refresh then ctrl:Refresh() end - end - end - - -- Tab metrics, shared by RefreshTabs (width) and tab creation (label inset): - -- the label must clear the status pip on the left and the × on the right. - local TAB_LABEL_LEFT, TAB_LABEL_RIGHT = 22, 24 - local TAB_MIN_W, TAB_MAX_W = 96, 160 - local function RefreshTabs() - local count = #db.pinnedFrames.sets - -- Adaptive tab width: share the strip so a couple of sets get roomy - -- tabs, clamped 120-160 so four still fit. Reserve room for the meter - -- (right) and the + Add button (shown when below the cap). Use the - -- fixed design width (NOT GetWidth) so the width is deterministic and - -- can't jump on a page rebuild where GetWidth() isn't settled yet. - local TAB_GAP = 4 -- small gap so the (now filled) cells read as distinct tabs - -- Lay out against the container's CURRENT width so the strip adapts when - -- the addon frame is resized narrow (fall back to the design width until - -- the first layout pass settles GetWidth). Re-flows via OnSizeChanged. - local cw = tabContainer:GetWidth() or 560 - if cw < 100 then cw = 560 end - local stripW = cw - 84 - if count < HIGHLIGHT_MAX_SETS then stripW = stripW - 70 end - -- Per-tab cap from the even share so the whole strip still fits when the - -- window is narrow; tabs otherwise HUG their label (see naturalW below) - -- rather than every tab stretching to a fixed width. - local maxPerTab = math.min(TAB_MAX_W, math.floor((stripW - (count - 1) * TAB_GAP) / math.max(count, 1))) - if maxPerTab < TAB_MIN_W then maxPerTab = TAB_MIN_W end - - local x = 0 -- running left offset; tabs are no longer uniform-width - for i, tab in ipairs(tabButtons) do - local set = db.pinnedFrames.sets[i] - if not set then - -- Tab button beyond the current set count — hide it. - tab:Hide() - else - tab:Show() - local isActive = (i == activeHighlightTab) - tab:SetActive(isActive) -- underline + accent/dim label - - -- Build the label first so the tab can be sized to it. - local displayName = set.name - if displayName == L["Pinned"] .. " " .. i or displayName == "" then displayName = L["Pinned"] .. " " .. i end - -- Show the pinned member count on the tab (player sets only — boss - -- sets auto-track boss1-8 and have no member list). - if set.frameType ~= "friendlyBoss" then - displayName = displayName .. " (" .. #(set.players or {}) .. ")" - end - tab.text:SetText(displayName) - - -- Hug the label (clamped): short names get a tight tab, long names - -- truncate at the cap instead of every tab being max width. - local naturalW = math.ceil(tab.text:GetStringWidth()) + TAB_LABEL_LEFT + TAB_LABEL_RIGHT - local tabW = math.max(TAB_MIN_W, math.min(maxPerTab, naturalW)) - tab:SetWidth(tabW) - tab:SetPoint("LEFT", tabContainer, "LEFT", x, 0) - x = x + tabW + TAB_GAP - - -- On/off pip, independent of the selected-tab highlight above. - if tab.statusDot then - if set.enabled then - tab.statusDot:SetVertexColor(0.30, 0.82, 0.38) -- green = enabled - else - tab.statusDot:SetVertexColor(0.32, 0.32, 0.32) -- grey = disabled - end - end - -- Remove (×) only on the active tab, and only when more than one - -- set exists (the last set can't be removed). Keeps the strip clean. - if tab.removeBtn then - if isActive and count > 1 then tab.removeBtn:Show() else tab.removeBtn:Hide() end - end - end - end - -- "+ Add set" sits just after the last set; hidden at the cap. - if addSetBtn then - if count < HIGHLIGHT_MAX_SETS then - addSetBtn:ClearAllPoints() - addSetBtn:SetPoint("LEFT", tabContainer, "LEFT", x + 4, 0) - addSetBtn:Show() - else - addSetBtn:Hide() - end - end - -- Count + active-set meter (each enabled set is a live secure header). - if setMeta then - local active = 0 - for _, s in ipairs(db.pinnedFrames.sets) do if s.enabled then active = active + 1 end end - setMeta:SetText(count .. "/" .. HIGHLIGHT_MAX_SETS .. " " .. active .. " " .. L["active"]) - if active >= 4 then setMeta:SetTextColor(0.95, 0.7, 0.2) else setMeta:SetTextColor(0.45, 0.45, 0.45) end - end - end - - -- ===== HEADER GROUP (full width) ===== - local headerGroup = GUI:CreateSettingsGroup(self.child, 560) - headerGroup:AddWidget(GUI:CreateHeader(self.child, L["Pinned Frames"]), 40) - -- Auto-size the description's slot to the actual wrapped text height so the - -- box hugs the text at every width (no fixed bottom padding, no truncation). - -- GetStringHeight returns a stale single-line value right after a width - -- change, so we measure on a DEFERRED frame (OnSizeChanged -> C_Timer) once - -- the FontString has re-wrapped, then update the group's slot height and - -- bubble a relayout up to the page. Mirrors GUI:CreateInfoBanner. - local pinnedDescLabel = GUI:CreateLabel(self.child, L["Create separate frame groups to pin specific players like tanks, healers, or key raid members, or to track NPC frames. Add players using the Members tab."], 530) - do - local descFS - for _, r in ipairs({ pinnedDescLabel:GetRegions() }) do - if r.GetStringHeight then descFS = r break end - end - local applying, lastW = false, nil - local function ApplyDescHeight() - if applying or not descFS or not pinnedDescLabel:IsVisible() then return end - local g = pinnedDescLabel.settingsGroup - if not g then return end - local desired = math.ceil(descFS:GetStringHeight() or 18) + 6 - for _, entry in ipairs(g.groupChildren) do - if entry.widget == pinnedDescLabel then - if entry.height ~= desired then - applying = true - entry.height = desired - g:LayoutChildren() - local p = g:GetParent() -- bubble so the page's column layout sees the new height - while p do - if type(p.RefreshStates) == "function" and p.children then p:RefreshStates() break end - p = p:GetParent() - end - applying = false - end - break - end - end - end - local function ScheduleApply() - if C_Timer and C_Timer.After then C_Timer.After(0, ApplyDescHeight) else ApplyDescHeight() end - end - pinnedDescLabel:SetScript("OnSizeChanged", function(_, w) - if w == lastW then return end -- only width changes affect wrap height - lastW = w - ScheduleApply() - end) - -- Re-measure when the page surfaces (GetStringHeight is unreliable while - -- hidden) and once on build in case the width never changes. - pinnedDescLabel:SetScript("OnShow", function() lastW = nil ScheduleApply() end) - ScheduleApply() - end - -- Initial slot fits 2 lines; the deferred measure grows/shrinks it to fit. - headerGroup:AddWidget(pinnedDescLabel, 34) - Add(headerGroup, nil, "both") - - -- Tab container - tabContainer = CreateFrame("Frame", nil, self.child) - tabContainer:SetSize(560, 32) - Add(tabContainer, 32, "both") - - -- Baseline track running under the whole tab strip (at the tabs' bottom - -- edge). The active tab's accent underline sits on this, giving the - -- AD-style tab-bar effect as you switch between tabs. - local tabBaseline = tabContainer:CreateTexture(nil, "ARTWORK") - tabBaseline:SetTexture("Interface\\Buttons\\WHITE8x8") - tabBaseline:SetHeight(1) - tabBaseline:SetPoint("BOTTOMLEFT", tabContainer, "BOTTOMLEFT", 0, 1) - tabBaseline:SetPoint("BOTTOMRIGHT", tabContainer, "BOTTOMRIGHT", 0, 1) - local tabBaseClr = (GUI.Colors and GUI.Colors.border) or { r = 0.25, g = 0.25, b = 0.25 } - tabBaseline:SetColorTexture(tabBaseClr.r, tabBaseClr.g, tabBaseClr.b, 0.5) - - -- Re-flow the strip when the addon frame is resized so the tabs share the - -- current width instead of overflowing into the meter. RefreshTabs is - -- nil-guarded for the buttons it touches, so an early fire is harmless. - tabContainer:SetScript("OnSizeChanged", function() RefreshTabs() end) - - for i = 1, HIGHLIGHT_MAX_SETS do - local tab = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") - tab:SetSize(120, 30) - tab:SetPoint("LEFT", tabContainer, "LEFT", (i - 1) * 120, 0) - -- Underline tab on the shared styler; RefreshTabs drives SetActive. - GUI:StyleButton(tab, { tab = true, text = L["Pinned"] .. " " .. i, font = "DFFontHighlight" }) - tab.text = tab.Text -- RefreshTabs sets the dynamic name (with count) here - -- Pin the label between the status pip (left) and the × (right) and - -- ellipsis-truncate, so a long name can't bleed into the next - -- (now edge-to-edge) tab and never overlaps the pip/×. - tab.text:ClearAllPoints() - tab.text:SetPoint("LEFT", tab, "LEFT", TAB_LABEL_LEFT, 0) - tab.text:SetPoint("RIGHT", tab, "RIGHT", -TAB_LABEL_RIGHT, 0) - tab.text:SetJustifyH("CENTER") - tab.text:SetWordWrap(false) - -- Status pip on the left: green = set enabled, dim grey = disabled. - -- Independent of the active-tab highlight (border + text colour), so a - -- set's on/off state is visible whether or not it's the selected tab. - tab.statusDot = tab:CreateTexture(nil, "OVERLAY") - tab.statusDot:SetTexture("Interface\\Buttons\\WHITE8x8") - tab.statusDot:SetSize(7, 7) - tab.statusDot:SetPoint("LEFT", tab, "LEFT", 8, 0) - -- Remove (×) button on the right — shown by RefreshTabs only on the - -- active tab when more than one set exists. Confirms before removing. - tab.removeBtn = GUI:CreateCloseButton(tab, { size = 16, onClick = function() DoRemoveSet(i) end }) - tab.removeBtn:SetPoint("RIGHT", tab, "RIGHT", -4, 0) - tab.removeBtn:Hide() - tab:SetScript("OnClick", function() - local oldSet = GetCurrentSet() - local oldType = oldSet and oldSet.frameType - activeHighlightTab = i - pagePinnedFrames.persistedTab = i - local newSet = GetCurrentSet() - local newType = newSet and newSet.frameType - RefreshTabs() - if oldType ~= newType and GUI.RefreshCurrentPage then - -- Frame type differs between tabs — invalidate cache so the page - -- rebuilds with the correct set of widgets for the new frame type. - if GUI.InvalidatePage then GUI:InvalidatePage(GUI.CurrentPageName) end - GUI.RefreshCurrentPage() - else - RefreshControls() - -- The newly-selected set may differ in enabled state, so re-run the - -- disabled-gating: sub-tab visibility + per-control hideOn reflow. - if RefreshSubTabs then RefreshSubTabs() end - self:RefreshStates() - if GUI.RefreshAllOverrideIndicators then GUI.RefreshAllOverrideIndicators() end - end - end) - tabButtons[i] = tab - end - - -- "+ Add set" button — RefreshTabs positions it after the last set and - -- hides it at the cap. Adds a (disabled) set to every mode + jumps to it. - addSetBtn = CreateFrame("Button", nil, tabContainer, "BackdropTemplate") - addSetBtn:SetSize(64, 28) - -- Ghost action: a faint cell (matching the tabs) with an accent "+ Add" - -- that brightens on hover — consistent with the strip, quiet add action. - GUI:StyleButton(addSetBtn, { ghost = true, icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\add", size = 14 }, text = L["Add"], font = "DFFontHighlight" }) - addSetBtn:SetScript("OnClick", DoAddSet) - - -- Count / active-set meter, right of the strip (each enabled set is a live - -- secure header — surfacing the active count makes the perf cost visible). - setMeta = tabContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - setMeta:SetPoint("RIGHT", tabContainer, "RIGHT", -2, 0) - setMeta:SetTextColor(0.45, 0.45, 0.45) - - RefreshTabs() - - -- ===== SUB-TABS (Setup / Appearance / Members) ===== - -- Splits the set editor so each concern has its own tab and no single page - -- is a long scroll. Switching just toggles group visibility via hideOn + a - -- RefreshStates() reflow (no page rebuild). The Members tab only exists for - -- player sets (boss sets auto-track boss1-8 and have no roster); the page - -- rebuilds on a frame-type change, so this list is rebuilt with it. - if activeSubTab == "members" and IsCurrentBossMode() then activeSubTab = "setup" end - local subTabDefs = { { key = "setup", label = L["Setup"] }, { key = "appearance", label = L["Appearance"] } } - if not IsCurrentBossMode() then - table.insert(subTabDefs, { key = "members", label = L["Members"] }) - end - local subTabButtons = {} - local subTabContainer = CreateFrame("Frame", nil, self.child) - subTabContainer:SetSize(460, 24) - RefreshSubTabs = function() - -- A disabled set snaps selection to Setup (its only live content is the - -- Enable toggle; the Setup controls + Frame Type grey in place). - local disabled = PinnedSetDisabled() - if disabled then activeSubTab = "setup" end - local x = 0 - for _, b in ipairs(subTabButtons) do - b:Show() - b:ClearAllPoints() - b:SetPoint("LEFT", subTabContainer, "LEFT", x, 0) - x = x + b:GetWidth() + 6 - b:SetActive(b.key == activeSubTab) -- filled toggle (white label both states) - -- While the set is off, grey + deactivate the non-Setup tabs: dim the - -- whole button (SetAlpha) and disable its mouse (EnableMouse false) so - -- there's NO hover wash and no clicks; Setup stays live. We use - -- SetAlpha+EnableMouse rather than StyleButton:SetDisabled, which fought - -- the hover wash and rendered a solid bright fill on hover. - local greyTab = disabled and b.key ~= "setup" - b:EnableMouse(not greyTab) - b:SetAlpha(greyTab and 0.4 or 1) - end - end - local subX = 0 - for i, def in ipairs(subTabDefs) do - local b = CreateFrame("Button", nil, subTabContainer, "BackdropTemplate") - b.key = def.key - b:SetHeight(22) - -- Filled toggle on the shared styler; RefreshSubTabs drives SetActive. - GUI:StyleButton(b, { text = def.label }) - -- Content-sized + flowed so this secondary row stays compact (clearly - -- subordinate to the underline tabs above), like AD's chips. - b:SetWidth(math.ceil(b.Text:GetStringWidth()) + 24) - b:SetPoint("LEFT", subTabContainer, "LEFT", subX, 0) - subX = subX + b:GetWidth() + 6 - b:SetScript("OnClick", function(self) - if self.dfDisabled then return end -- greyed tab (set disabled) — ignore clicks - activeSubTab = def.key - pagePinnedFrames.persistedSubTab = def.key - RefreshSubTabs() - pagePinnedFrames:RefreshStates() -- reflow: hideOn predicates re-evaluate against activeSubTab - end) - subTabButtons[i] = b - end - RefreshSubTabs() - AddSpace(6, "both") -- breathing room between the tab strip and the sub-row - Add(subTabContainer, 26, "both") - - AddSpace(GUI.Space.section, "both") - - -- Helper to get the pinned override key for the current active tab - local function GetPinnedKey(dbKey) - return "pinned." .. activeHighlightTab .. "." .. dbKey - end - - -- Add override indicators (star, reset, global text) to a pinned frame control - local function AddPinnedOverrideIndicators(container, lbl, dbKey, onReset) - local AutoProfilesUI = DF.AutoProfilesUI - if not AutoProfilesUI then return end - - -- Reset button (red, icon-only) + override marker (dot) — shared helpers. - local resetBtn = GUI:CreateOverrideResetButton(container, { - tooltip = L["Reset to Global"], - tooltipDesc = L["Reset this setting to its global value."], - onClick = function() if onReset then onReset() end end, - }) - resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 0) - container.overrideResetBtn = resetBtn - - local starFrame = GUI:CreateOverrideMarker(container) - starFrame:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) - container.overrideStar = starFrame - - -- Global value text - local globalText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - globalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - globalText:SetTextColor(0.4, 0.4, 0.4) - globalText:Hide() - container.overrideGlobalText = globalText - - -- Checkmark icon - local checkIcon = container:CreateTexture(nil, "OVERLAY") - checkIcon:SetSize(8, 8) - checkIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\check") - checkIcon:SetVertexColor(0.3, 0.7, 0.3) - checkIcon:Hide() - container.overrideCheckIcon = checkIcon - - container.UpdateOverrideIndicators = function(self) - -- Only the per-set `enabled` flag is layout-overridable now; every - -- other pinned setting is global/independent of auto layouts, so it - -- shows no override UI at all (no star, reset, or "Global:" text). - if not (DF.AutoProfilesUI and DF.AutoProfilesUI.IsPinnedSettingOverridable - and DF.AutoProfilesUI:IsPinnedSettingOverridable(dbKey)) then - self.overrideStar:Hide(); self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide(); self.overrideCheckIcon:Hide() - return - end - -- Debug mode - if GUI.IsOverrideDebugMode and GUI.IsOverrideDebugMode() then - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideGlobalText:SetText("(debug)") - self.overrideGlobalText:SetTextColor(1, 0.8, 0.2) - self.overrideGlobalText:Show() - self.overrideCheckIcon:Hide() - return - end - - -- Only show in raid mode while editing - if not GUI or GUI.SelectedMode ~= "raid" then - self.overrideStar:Hide(); self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide(); self.overrideCheckIcon:Hide() - return - end - - local isEditing = AutoProfilesUI and AutoProfilesUI:IsEditing() - local pinnedKey = GetPinnedKey(dbKey) - local isRuntimeOverridden = AutoProfilesUI and AutoProfilesUI:IsOverriddenByRuntime(pinnedKey) - - -- Hide everything if not editing AND not runtime-overridden - if not isEditing and not isRuntimeOverridden then - self.overrideStar:Hide(); self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide(); self.overrideCheckIcon:Hide() - return - end - - -- Runtime override mode: show star + global value, no reset button - if isRuntimeOverridden and not isEditing then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting is being overridden by the active auto layout profile. To change it, edit the profile in the Auto Layouts tab."] - self.overrideStar:Show() - self.overrideResetBtn:Hide() - self.overrideCheckIcon:Hide() - - local globalValue = AutoProfilesUI:GetRuntimeGlobalValue(pinnedKey) - local globalDisplay - if type(globalValue) == "boolean" then - globalDisplay = globalValue and L["Yes"] or L["No"] - elseif type(globalValue) == "number" then - if globalValue == math.floor(globalValue) then - globalDisplay = tostring(globalValue) - else - globalDisplay = string.format("%.2f", globalValue) - end - elseif type(globalValue) == "table" then - globalDisplay = "..." - else - globalDisplay = tostring(globalValue or "None") - end - - self.overrideGlobalText:SetText(L["Global: "] .. globalDisplay) - self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) - self.overrideGlobalText:Show() - return - end - - -- Editing mode: existing behavior - local isOverridden = AutoProfilesUI:IsSettingOverridden(pinnedKey) - local globalValue = AutoProfilesUI:GetGlobalValue(pinnedKey) - - if isOverridden then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] - self.overrideStar:Show() - self.overrideResetBtn:Show() - else - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - end - - -- Format global value for display - local globalDisplay - if type(globalValue) == "boolean" then - globalDisplay = globalValue and L["Yes"] or L["No"] - elseif type(globalValue) == "number" then - if globalValue == math.floor(globalValue) then - globalDisplay = tostring(globalValue) - else - globalDisplay = string.format("%.2f", globalValue) - end - elseif type(globalValue) == "table" then - globalDisplay = "..." - else - globalDisplay = tostring(globalValue or "None") - end - - -- Show global text with check/star positioning - if isOverridden then - self.overrideGlobalText:SetText(L["Global: "] .. globalDisplay) - self.overrideGlobalText:SetTextColor(0.4, 0.4, 0.4) - self.overrideGlobalText:Show() - self.overrideCheckIcon:Hide() - else - self.overrideGlobalText:SetText(L["Global: "] .. globalDisplay) - self.overrideGlobalText:SetTextColor(0.3, 0.7, 0.3) - self.overrideGlobalText:Show() - self.overrideCheckIcon:SetPoint("RIGHT", self.overrideGlobalText, "LEFT", -2, 0) - self.overrideCheckIcon:Show() - end - end - - -- Register for global refresh - if GUI.RegisterOverrideWidget then - GUI.RegisterOverrideWidget(container) - end - end - - -- Helper function to create refreshable checkbox - local function CreateRefreshableCheckbox(parent, label, dbKey, callback, tooltip) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(250, 24) - local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") - cb:SetPoint("LEFT", 0, 0) - GUI:StyleCheckButton(cb, { themeRoot = parent }) - local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - txt:SetText(label) - txt:SetTextColor(0.8, 0.8, 0.8) - if tooltip then - cb:SetScript("OnEnter", function(s) - GUI:ShowTooltip(s, { - title = label, - lines = { tooltip }, - }) - end) - cb:SetScript("OnLeave", function() GUI:HideTooltip() end) - end - cb:SetScript("OnClick", function(s) - local val = s:GetChecked() - -- Runtime override protection - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(GetPinnedKey(dbKey), val) then - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - return - end - GetCurrentSet()[dbKey] = val - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey(dbKey), val) - end - if callback then callback(GetCurrentSet()) end - DF:UpdateAll() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end) - container.Refresh = function() - cb:SetChecked(GetCurrentSet()[dbKey]) - -- Optional disabled state: when container.enabledWhen() is false the - -- checkbox is greyed and can't be toggled (used where one toggle is - -- only meaningful while another option is in a particular state). - if container.enabledWhen then - if container.enabledWhen() then - cb:Enable() - txt:SetTextColor(0.8, 0.8, 0.8) - cb.Check:SetVertexColor(tc.r, tc.g, tc.b) - else - cb:Disable() - txt:SetTextColor(0.4, 0.4, 0.4) - cb.Check:SetVertexColor(0.4, 0.4, 0.4) - end - end - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - - -- Override indicators with reset - AddPinnedOverrideIndicators(container, txt, dbKey, function() - local AutoProfilesUI = DF.AutoProfilesUI - if AutoProfilesUI then - AutoProfilesUI:ResetProfileSetting(GetPinnedKey(dbKey)) - cb:SetChecked(GetCurrentSet()[dbKey]) - if callback then callback(GetCurrentSet()) end - DF:UpdateAll() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - end) - - -- Group-gate hook: lets settingsGroup.disableChildrenOn grey this checkbox - -- in place (dim the box + label, block toggling) without hiding it. - container.SetEnabled = function(_, enabled) - if enabled then cb:Enable() else cb:Disable() end - txt:SetTextColor(0.8, 0.8, 0.8) - txt:SetAlpha(enabled and 1 or 0.4) - cb:SetAlpha(enabled and 1 or 0.4) - end - - container.Refresh() - table.insert(controlsToRefresh, container) - return container - end - - -- Helper function to create refreshable slider - -- - -- Delegates the slider chrome/value plumbing to the shared GUI:CreateSlider - -- builder (theme colour). The pinned value lives in GetCurrentSet()[dbKey], - -- so we drive CreateSlider via customGet/customSet (dbKey passed as nil so - -- CreateSlider's own raw-key runtime/profile/override paths stay off — the - -- pinned system keys off the PREFIXED GetPinnedKey(dbKey) instead, handled - -- here in customSet + AddPinnedOverrideIndicators below). - local function CreateRefreshableSlider(parent, label, minVal, maxVal, step, dbKey, callback) - local container - local function customGet() - return GetCurrentSet()[dbKey] or minVal - end - local function customSet(value) - -- Runtime override protection (raid auto-layout): redirect the write - -- to the active profile baseline and skip the set write entirely. - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(GetPinnedKey(dbKey), value) then - if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - return - end - GetCurrentSet()[dbKey] = value - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey(dbKey), value) - end - if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - container = GUI:CreateSlider(parent, label, minVal, maxVal, step, nil, nil, callback, nil, nil, customGet, customSet) - - -- Refresh: re-read the set's value into the slider (used on page rebuild - -- and Match-mode changes). The `updating`/suppressCallback guard inside - -- CreateSlider's UpdateValue means this programmatic SetValue does not - -- re-fire the user callback or re-write the db. - container.Refresh = function() - container.slider:SetValue(GetCurrentSet()[dbKey] or minVal) - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - container.SetValue = function(_, val) - container.slider:SetValue(val) - end - - -- Override indicators with reset (prefixed pinned key). - AddPinnedOverrideIndicators(container, container.label, dbKey, function() - local AutoProfilesUI = DF.AutoProfilesUI - if AutoProfilesUI then - AutoProfilesUI:ResetProfileSetting(GetPinnedKey(dbKey)) - container.slider:SetValue(GetCurrentSet()[dbKey] or minVal) - if callback then callback() end - DF:UpdateAll() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - end) - - container.Refresh() - table.insert(controlsToRefresh, container) - return container - end - - -- Slider whose baseline value comes from the set's Match mode (the - -- party/raid main-frame field `baselineKey`, e.g. "frameWidth"), with - -- auto-layout-style override UX: changing it stores a per-set override in - -- set[overrideKey] (e.g. "customWidth"); a gold star + reset button appear, - -- and reset clears the override to revert to the Match value. This makes - -- "Match sets the value, the user overrides it" read the same as a layout - -- override. These keys are NOT auto-layout overridable, so there is no - -- runtime/HandleRuntimeWrite path — writes go straight to the set. - local function CreateMatchOverrideSlider(parent, label, minVal, maxVal, step, overrideKey, baselineKey, callback) - -- Slider chrome/value plumbing delegated to GUI:CreateSlider (theme - -- colour). dbKey is passed as nil (these keys are NOT auto-layout - -- overridable, so CreateSlider's raw-key runtime/profile/override paths - -- must stay off) and the value is driven via customGet/customSet, which - -- read the EffectiveValue and store the per-set override below. - local container - - local function MatchValue() - local set = GetCurrentSet() - local mode = (set and set.matchMode) or GUI.SelectedMode - local mdb = DF:GetDB(mode) - -- baselineKey may be a function (mdb, set) -> value, for settings - -- whose inherited source is mode-dependent (e.g. spacing: grouped - -- raid uses frameSpacing, flat raid uses raidFlat*Spacing). - if type(baselineKey) == "function" then - return baselineKey(mdb, set) or minVal - end - return (mdb and mdb[baselineKey]) or minVal - end - -- Float-tolerant compare (half a step): fractional-step slider drags - -- produce values like 0.5999999, so exact == against the baseline - -- never matched and dragging Scale back to the inherited value left - -- a stale override + star at an identical-looking number. - local function MatchesBaseline(v) - local m = MatchValue() - return v ~= nil and m ~= nil and math.abs(v - m) < (step * 0.5) - end - local function IsOverridden() - local set = GetCurrentSet() - return set ~= nil and set[overrideKey] ~= nil and not MatchesBaseline(set[overrideKey]) - end - local function EffectiveValue() - local set = GetCurrentSet() - return (set and set[overrideKey]) or MatchValue() - end - -- Store an override only when it differs from the inherited value; setting - -- it back to the inherited value clears it (so no stale star remains). - local function SetOverride(v) - if MatchesBaseline(v) then GetCurrentSet()[overrideKey] = nil - else GetCurrentSet()[overrideKey] = v end - end - local function FmtVal(v) return step < 1 and string.format("%.1f", v) or string.format("%d", v) end - - -- Forward-declared so customSet (below) can refresh the star/reset after - -- each write; assigned once the indicator frames exist. - local UpdateIndicators - - -- customGet/customSet drive the shared slider: the displayed value is the - -- EffectiveValue (override if set, else the Match baseline) and a user - -- edit stores/clears the per-set override. dbKey is nil so CreateSlider's - -- raw-key runtime/profile/override machinery stays off. - local function customGet() return EffectiveValue() end - local function customSet(v) - SetOverride(v) - if UpdateIndicators then UpdateIndicators() end - end - container = GUI:CreateSlider(parent, label, minVal, maxVal, step, nil, nil, callback, nil, nil, customGet, customSet) - - -- Reset-to-Match button (TOPRIGHT) + gold override star to its left, - -- mirroring AddPinnedOverrideIndicators so it reads like a layout override. - -- Reset-to-Match (red, icon-only) + override marker (dot) — shared - -- helpers. The marker tooltip is dynamic (shows the inherited value) - -- so it's set below; the reset OnClick is wired further down. - local resetBtn = GUI:CreateOverrideResetButton(container, { tooltip = L["Reset to inherited value"] }) - resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 0) - - local starFrame = GUI:CreateOverrideMarker(container) - starFrame:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) - starFrame:SetScript("OnEnter", function(s) - GUI:ShowTooltip(s, { - title = L["Override active"], - lines = { string.format(L["Inherited value: %s"], FmtVal(MatchValue())) }, - }) - end) - starFrame:SetScript("OnLeave", function() GUI:HideTooltip() end) - - UpdateIndicators = function() - if IsOverridden() then starFrame:Show(); resetBtn:Show() else starFrame:Hide(); resetBtn:Hide() end - end - -- Also exposed so CreateSlider's own handlers (drag-end etc.) refresh it. - container.UpdateOverrideIndicators = UpdateIndicators - - -- Reset clears the per-set override and snaps the slider back to the - -- inherited Match value. The slider's programmatic SetValue is guarded by - -- CreateSlider's suppressCallback, so this does not re-write an override. - resetBtn:SetScript("OnClick", function() - GetCurrentSet()[overrideKey] = nil - container.slider:SetValue(EffectiveValue()) - if callback then callback() end - UpdateIndicators() - end) - - container.Refresh = function() - container.slider:SetValue(EffectiveValue()) - UpdateIndicators() - end - container.Refresh() - table.insert(controlsToRefresh, container) - return container - end - - -- Helper function to create refreshable dropdown - -- - -- Delegates the dropdown chrome/menu plumbing to the shared GUI:CreateDropdown - -- builder (theme colour). The pinned value lives in GetCurrentSet()[dbKey], - -- so we drive CreateDropdown via customGet/customSet (dbKey passed as nil so - -- the builder's own raw-key runtime/profile/override paths stay off — the - -- pinned system keys off the PREFIXED GetPinnedKey(dbKey) instead, handled - -- here in customSet + AddPinnedOverrideIndicators below). Mirrors - -- CreateRefreshableSlider's structure exactly. - local function CreateRefreshableDropdown(parent, label, options, dbKey, callback) - local container - local function customGet() - return GetCurrentSet()[dbKey] - end - local function customSet(value) - -- Runtime override protection (raid auto-layout): redirect the write - -- to the active profile baseline and skip the set write entirely. - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(GetPinnedKey(dbKey), value) then - if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - return - end - GetCurrentSet()[dbKey] = value - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey(dbKey), value) - end - if container and container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - container = GUI:CreateDropdown(parent, label, options, nil, nil, callback, customGet, customSet) - - -- Refresh: re-read the set's value into the dropdown text (used on page - -- rebuild and Match-mode changes). UpdateText reads via customGet, so it - -- never re-writes the db or re-fires the callback. - container.Refresh = function() - container:UpdateText() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - - -- Override indicators with reset (prefixed pinned key). - AddPinnedOverrideIndicators(container, container.label, dbKey, function() - local AutoProfilesUI = DF.AutoProfilesUI - if AutoProfilesUI then - AutoProfilesUI:ResetProfileSetting(GetPinnedKey(dbKey)) - container:UpdateText() - if callback then callback() end - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators() end - end - end) - - container.Refresh() - table.insert(controlsToRefresh, container) - return container - end - - -- Per-set Aura/Text Designer preset picker. Unlike CreateRefreshableDropdown - -- its menu is rebuilt every open (the preset library grows/shrinks as presets - -- are created/renamed/deleted on the AD/TD pages), and the first entry, - -- "Inherit", maps to nil — the set then follows its mode's preset via the - -- resolver's FrameMode fallback. Preset refs are global-per-mode (never an - -- auto-layout override), so this writes straight to the set with no star. - -- `kind` is "aura" or "text"; `dbKey` the matching set ref. - local function CreatePinnedPresetDropdown(parent, label, kind, dbKey, callback) - local container - -- Sentinel option KEY for the "Inherit" row. Stored value nil means - -- inherit, but option tables cannot be keyed by nil, so the dropdown - -- carries this string key and customGet/customSet translate nil<->INHERIT. - local INHERIT = "__inherit__" - - local function InheritLabel() - local modeName = (DF.GetModeDesignerPresetName and DF:GetModeDesignerPresetName(kind, GUI.SelectedMode)) - or DF.DEFAULT_PRESET - return L["Inherit"] .. " (" .. tostring(modeName) .. ")" - end - - -- Dynamic option list, rebuilt on every open (the preset library grows/ - -- shrinks as presets are created/renamed/deleted on the AD/TD pages). - -- _order keeps Inherit first, then the preset names in ListDesignerPresets - -- order (DEFAULT_PRESET first, the rest sorted) — matching the old menu. - local function BuildOptions() - local opts = { [INHERIT] = InheritLabel() } - local order = { INHERIT } - for _, name in ipairs(DF:ListDesignerPresets(kind)) do - opts[name] = name - order[#order + 1] = name - end - opts._order = order - return opts - end - - -- Value get/set: nil (no per-set ref) reads as INHERIT; selecting INHERIT - -- clears the set ref back to nil so the set follows its mode's preset. - -- Preset refs are global-per-mode (never an auto-layout override), so this - -- writes straight to the set with no star (no AddPinnedOverrideIndicators). - local function customGet() - local set = GetCurrentSet() - local cur = set and set[dbKey] - return cur or INHERIT - end - local function customSet(value) - local set = GetCurrentSet() - if set then set[dbKey] = (value ~= INHERIT) and value or nil end - end - - container = GUI:CreateDropdown(parent, label, BuildOptions(), nil, nil, callback, - customGet, customSet, { optionsFunc = BuildOptions }) - - -- Refresh re-reads the set's ref into the button text (the Inherit label - -- also re-resolves for the current mode). UpdateText reads via customGet, - -- so it never re-writes the set or re-fires the callback. - container.Refresh = function() - container:UpdateText() - end - - container.Refresh() - table.insert(controlsToRefresh, container) - return container - end - - -- Helper function to update layout - local function UpdateHighlightLayout() - if DF.PinnedFrames then - DF.PinnedFrames:ApplyLayoutSettings(activeHighlightTab) - DF.PinnedFrames:ResizeContainer(activeHighlightTab) - -- If a preview container is active for the edited mode, keep it in sync - DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) - end - end - - -- Forward declaration for roster widget and unit selection header - local rosterWidget - local unitSelHeader - - -- Helper: sync players array to override system after auto-populate - local function SyncPlayersOverride() - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - local players = GetCurrentSet().players - local copy = {} - for i, v in ipairs(players) do copy[i] = v end - DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey("players"), copy) - if unitSelHeader and unitSelHeader.UpdateOverrideIndicators then - unitSelHeader:UpdateOverrideIndicators() - end - end - end - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - - -- While editing a raid auto layout, make the decouple explicit via an info - -- banner: only the per-set Enable flag can differ per layout; everything - -- else is shared. Hidden unless editing a raid layout. - local pinnedLayoutNote = GUI:CreateInfoBanner(self.child, { - tone = "info", - text = L["Auto layouts can only change whether pinned frames are shown (Enable). All other pinned frame settings are shared across layouts."], - }) - pinnedLayoutNote.hideOn = function() - return not (GUI.SelectedMode == "raid" and DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing()) - end - settingsGroup:AddWidget(pinnedLayoutNote, pinnedLayoutNote.layoutHeight or 44) - - -- SetEnabled / SetShowLabel internally use GetSetDB → IsInRaid(), - -- so calling them while editing the inactive mode would mutate the active - -- mode's state. Only call them when the selected mode matches the live mode; - -- otherwise the DB write from the checkbox itself is enough and the preview - -- reflects the change. - local function IsEditingActiveMode() - local actualMode = IsInRaid() and "raid" or "party" - return GUI.SelectedMode == actualMode - end - - -- Refresh Test Mode frames if active — enable/lock toggles affect - -- mover visibility and whether test frames should render at all. - local function RefreshTestModeIfActive() - if DF.PinnedFrames.IsTestModeActive and DF.PinnedFrames:IsTestModeActive() then - DF.PinnedFrames:ExitTestMode() - DF.PinnedFrames:EnterTestMode() - end - end - - local pinnedEnableCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Enable"], "enabled", function() - -- Enabling/disabling a set greys/ungreys the rest of its tabs + controls. - RefreshSubTabs() - self:RefreshStates() - if not DF.PinnedFrames then return end - if IsEditingActiveMode() then - DF.PinnedFrames:SetEnabled(activeHighlightTab, GetCurrentSet().enabled) - end - DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) - RefreshTestModeIfActive() - RefreshTabs() -- update the on/off pip on this set's tab - end), 28) - -- The Enable toggle itself must stay live while its set is disabled (it's the - -- only way back on); disableChildrenOn below greys every OTHER Setup control. - pinnedEnableCheck.keepEnabled = true - -- Pinned frames now lock/unlock together with the main frames (global - -- lock), so there is no per-set Lock Position toggle. Show Label is always - -- editable; the "Drag to Move" handle only appears while globally unlocked. - settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Show Label"], "showLabel", function() - if not DF.PinnedFrames then return end - if IsEditingActiveMode() then - DF.PinnedFrames:SetShowLabel(activeHighlightTab, GetCurrentSet().showLabel) - end - DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) - RefreshTestModeIfActive() - end), 28) - - -- Party-only: show this pinned set while solo (off by default — pinned - -- frames highlight other group members). Raid implies a group, so hide it - -- in raid mode. - local soloCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Show in Solo Mode"], "showInSoloMode", function() - if not DF.PinnedFrames then return end - -- Re-apply visibility so the solo gate takes effect immediately. - DF.PinnedFrames:SetEnabled(activeHighlightTab, GetCurrentSet().enabled) - RefreshTestModeIfActive() - end), 28) - soloCheck.hideOn = function() return GUI.SelectedMode == "raid" end - - -- Declutter toggles: hide auras / status icons on this set's frames for a - -- clean highlight. Re-stamp the effective DB and re-render so it applies live. - local function RefreshPinnedDisplay() - UpdateHighlightLayout() - if DF.UpdateAll then DF:UpdateAll() end - if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - RefreshTestModeIfActive() - end - settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Hide Auras"], "hideAuras", RefreshPinnedDisplay), 28) - settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Hide Status Icons"], "hideIcons", RefreshPinnedDisplay), 28) - - -- Hide from Main Frames (#78): when on, this set's members are filtered out - -- of the main party/raid frames so they only appear in the pinned set. Re- - -- filter the main headers on toggle (out of combat). Boss sets pin boss units, - -- not main-frame members, so it's moot there → hidden in boss mode. - local hideMainCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Hide from Main Frames"], "hideFromMainFrames", function() - RefreshPinnedDisplay() - -- Defer past CreateRefreshableCheckbox's trailing DF:UpdateAll() so the - -- re-filter isn't stomped, then re-apply the main-frame sort directly. - C_Timer.After(0, function() - if DF.RefreshMainFrameSorting then DF:RefreshMainFrameSorting() end - end) - end, L["Hide from Main Frames Tooltip"]), 28) - hideMainCheck.hideOn = function() return IsCurrentBossMode() end - - -- Disable in PvP (GLOBAL across both modes, not per-set): keep pinned frames - -- dormant in all instanced PvP. Default on — pinned is a party/raid feature - -- and the arena/BG event storm can exhaust the per-frame budget. Turning it - -- off re-enables pinned there; the debounced RequestProcessAllSets keeps that - -- opt-in from stampeding. - -- - -- The runtime gate reads the CURRENT mode's pinnedFrames.disableInPvP (arena - -- resolves to party config, battlegrounds to raid). To make one checkbox act - -- globally — and to match the "Disable in PvP" label — the toggle writes BOTH - -- modes in lockstep, so whichever mode the gate resolves to sees the same - -- value. Hidden while editing a raid auto-layout (it's global, nothing - -- layout-specific to override); outside the editor DF.db.party/.raid are the - -- plain mode profiles, so the paired write lands on the real globals. - local function GetDisableInPvP() - local v = db.pinnedFrames.disableInPvP - if v == nil then return true end -- runtime gate treats nil as true - return v - end - local function SetDisableInPvP(val) - for _, m in ipairs({ "party", "raid" }) do - local mdb = DF.db and DF.db[m] - if mdb and mdb.pinnedFrames then - mdb.pinnedFrames.disableInPvP = val - end - end - end - local disablePvPContainer = CreateFrame("Frame", nil, self.child) - disablePvPContainer:SetSize(250, 24) - local dpvpCB = CreateFrame("CheckButton", nil, disablePvPContainer, "BackdropTemplate") - dpvpCB:SetPoint("LEFT", 0, 0) - GUI:StyleCheckButton(dpvpCB, { themeRoot = self.child }) - local dpvpTxt = disablePvPContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - dpvpTxt:SetPoint("LEFT", dpvpCB, "RIGHT", 8, 0) - dpvpTxt:SetTextColor(0.8, 0.8, 0.8) - dpvpCB:SetScript("OnClick", function(s) - SetDisableInPvP(s:GetChecked() and true or false) - -- Re-evaluate visibility for the live mode (debounced + combat-safe). - if DF.PinnedFrames and IsEditingActiveMode() and DF.PinnedFrames.RequestProcessAllSets then - DF.PinnedFrames:RequestProcessAllSets() - end - end) - dpvpCB:SetScript("OnEnter", function(s) - GUI:ShowTooltip(s, { - title = L["Disable in PvP"], - lines = { - L["Pinned frames are a party/raid feature. Leave on to keep them hidden in arena and battlegrounds, where the constant unit churn can hurt performance. Applies to both party and raid pinned sets."], - }, - }) - end) - dpvpCB:SetScript("OnLeave", function() GUI:HideTooltip() end) - disablePvPContainer.Refresh = function() - dpvpTxt:SetText(L["Disable in PvP"]) - dpvpCB:SetChecked(GetDisableInPvP()) - end - -- SetEnabled shim so settingsGroup.disableChildrenOn can grey this custom - -- container in place (dim the checkbox + label) when the set is disabled. - disablePvPContainer.SetEnabled = function(_, enabled) - dpvpCB:SetEnabled(enabled) - dpvpTxt:SetTextColor(0.8, 0.8, 0.8) - dpvpTxt:SetAlpha(enabled and 1 or 0.4) - dpvpCB:SetAlpha(enabled and 1 or 0.4) - end - -- Mode-global setting: not layout-overridable, so hide it while editing a - -- raid auto-layout (its banner already points users at the base settings). - disablePvPContainer.hideOn = function() - return DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() - end - disablePvPContainer.Refresh() - table.insert(controlsToRefresh, disablePvPContainer) - settingsGroup:AddWidget(disablePvPContainer, 28) - - -- Reset Position button - local resetPosBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") - GUI:StyleButton(resetPosBtn, { width = 130, height = 22, text = L["Reset Position"] }) - -- Route the group gate's SetEnabled through StyleButton's grey path so the - -- button dims in place (instead of native-disabling) while the set is off. - resetPosBtn.SetEnabled = function(self, enabled) self:SetDisabled(not enabled) end - resetPosBtn:SetScript("OnClick", function(self) - if self.dfDisabled then return end -- greyed (set disabled) — ignore clicks - local set = GetCurrentSet() - if not set or not DF.PinnedFrames then return end - - -- Reset position in the edited (selected) mode's DB - set.position = { point = "CENTER", x = 0, y = 0 } - - -- Apply to the real container only if editing the actual mode - local actualMode = IsInRaid() and "raid" or "party" - if GUI.SelectedMode == actualMode then - local container = DF.PinnedFrames.containers[activeHighlightTab] - if container then - container:ClearAllPoints() - container:SetPoint("CENTER", UIParent, "CENTER", 0, 0) - DF.PinnedFrames:ApplyLayoutSettings(activeHighlightTab) - end - end - - -- Keep the preview in sync if one is active for the edited mode - DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) - end) - settingsGroup:AddWidget(resetPosBtn, 28) - - -- Label name input - local nameInputContainer = CreateFrame("Frame", nil, self.child) - nameInputContainer:SetSize(250, 44) - local nameLabel = nameInputContainer:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - nameLabel:SetPoint("TOPLEFT", 0, 0) - nameLabel:SetText(L["Label Name"]) - nameLabel:SetTextColor(0.8, 0.8, 0.8) - local nameInput = CreateFrame("EditBox", nil, nameInputContainer, "BackdropTemplate") - nameInput:SetPoint("TOPLEFT", 0, -15) - nameInput:SetSize(220, 24) - GUI:StyleEditBox(nameInput) - nameInput:SetAutoFocus(false) - nameInput:SetMaxLetters(30) - nameInput:SetScript("OnEscapePressed", function(s) s:ClearFocus() end) - nameInput:SetScript("OnEnterPressed", function(s) s:ClearFocus() end) - nameInput:SetScript("OnEditFocusLost", function(s) - GetCurrentSet().name = s:GetText() - RefreshTabs() - if DF.PinnedFrames then - DF.PinnedFrames:UpdateLabel(activeHighlightTab) - -- Refresh preview label text too if a preview is active - DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) - end - end) - nameInputContainer.Refresh = function() nameInput:SetText(GetCurrentSet().name or "") end - -- SetEnabled shim: grey the label + editbox in place when the set is disabled. - nameInputContainer.SetEnabled = function(_, enabled) - nameInput:EnableMouse(enabled) - nameInput:EnableKeyboard(enabled) - if not enabled then nameInput:ClearFocus() end - nameInput:SetAlpha(enabled and 1 or 0.4) - nameLabel:SetAlpha(enabled and 1 or 0.4) - end - table.insert(controlsToRefresh, nameInputContainer) - settingsGroup:AddWidget(nameInputContainer, 48) - - Add(settingsGroup, nil, 1) - settingsGroup.hideOn = function() return activeSubTab ~= "setup" end -- Setup tab - - -- Disabled set → grey (disabled-in-place) every OTHER Setup control while the - -- Enable toggle stays live (keepEnabled above). Each control keeps its OWN - -- hideOn (soloCheck raid-mode, hideMainCheck boss-mode, disablePvPContainer - -- editing-layout) — those compose as variant/mode hides on top of the grey. - settingsGroup.disableChildrenOn = function() return PinnedSetDisabled() end - - -- ===== FRAME TYPE GROUP (Column 2) ===== - local frameTypeGroup = GUI:CreateSettingsGroup(self.child, 280) - local frameTypeHeader = GUI:CreateHeader(self.child, L["Frame Type"]) - -- Gold "New" badge next to the header (the Friendly Boss NPCs option was - -- introduced in 4.3.2). Clears when the user navigates away from the - -- Pinned Frames tab and stays cleared across sessions. - GUI:AddSectionNewBadge(frameTypeHeader, "general_pinnedframes", "frameType") - frameTypeGroup:AddWidget(frameTypeHeader, 40) - - local frameTypeOptions = { - player = L["Player Frames"], - friendlyBoss = L["Friendly Boss NPCs"], - } - - local function OnFrameTypeChanged() - if not DF.PinnedFrames then return end - -- No combat early-return: the dropdown already wrote set.frameType, - -- so bailing here left the page AND runtime desynced (Members tab - -- shown for a now-boss set, wrong Test Count max) until some later - -- rebuild. Reinitialize self-defers in combat (pendingReinitialize - -- → PLAYER_REGEN_ENABLED), and the page rebuild isn't secure work. - DF.PinnedFrames:Reinitialize() - if GUI.RefreshCurrentPage then GUI.RefreshCurrentPage() end - end - - frameTypeGroup:AddWidget( - CreateRefreshableDropdown(self.child, L["Frame Type"], frameTypeOptions, "frameType", OnFrameTypeChanged), - 55 - ) - - -- Test Count slider: how many test frames show when Test Mode is - -- active. Boss mode: 1–8 (hard WoW limit). Party player sets: 1–5 - -- (a party can't exceed 5). Raid player sets: 1–10 (covers typical - -- pinned set sizes; range kept modest for layout verification). - local function OnTestCountChanged() - if not DF.PinnedFrames then return end - if DF.PinnedFrames.IsTestModeActive and DF.PinnedFrames:IsTestModeActive() then - DF.PinnedFrames:ExitTestMode() - DF.PinnedFrames:EnterTestMode() - end - end - local testMax = IsCurrentBossMode() and 8 or (GUI.SelectedMode == "raid" and 10 or 5) - frameTypeGroup:AddWidget( - CreateRefreshableSlider(self.child, L["Test Count"], 1, testMax, 1, "testCount", OnTestCountChanged), - 55 - ) - - Add(frameTypeGroup, nil, 2) - frameTypeGroup.hideOn = function() return activeSubTab ~= "setup" end -- Setup tab - frameTypeGroup.disableChildrenOn = function() return PinnedSetDisabled() end -- greyed while the set is disabled - AddSpace(10, "both") - - -- ===== FRAME STYLE GROUP (Column 1) — inherited from your frames, overridable ===== - local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) - layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Style"]), 40) - - -- Up-front explainer for the Match inheritance + per-setting override model. - local matchInfoBanner = GUI:CreateInfoBanner(self.child, { - tone = "info", - text = L["Pinned frames are based on your Party or Raid frames — choose which below. Change any setting to override it for these frames; use the reset button beside an overridden setting to revert it to the inherited value."], - }) - layoutGroup:AddWidget(matchInfoBanner, matchInfoBanner.layoutHeight or 44) - - -- Match (Stage 2a): which mode's main frames this pinned set inherits its - -- baseline look from. Defaults to the page's OWN mode (a party set mirrors - -- party frames, a raid set mirrors raid frames); pick the opposite mode to - -- cross-match it (e.g. raid pinned frames sized/styled like party frames). - -- Per-set custom overrides (size, etc.) still win over the baseline. It - -- leads the group because it is the baseline every other option/override - -- builds on. Seed unset/legacy values to the own mode so it always shows one. - do - local pf = DF:GetDB(GUI.SelectedMode) - pf = pf and pf.pinnedFrames - if pf and pf.sets then - for _, s in pairs(pf.sets) do - if s.matchMode ~= "party" and s.matchMode ~= "raid" then - s.matchMode = GUI.SelectedMode - end - end - end - end - -- Forward refs so the Match dropdown can refresh every Match-override control's - -- displayed baseline when the matched mode changes (an un-overridden control - -- then shows the new mode's value; an overridden one keeps its star). - local pinnedWidthSlider, pinnedHeightSlider, pinnedScaleSlider - local pinnedHSpacingSlider, pinnedVSpacingSlider - local function RefreshMatchOverrides() - if pinnedWidthSlider then pinnedWidthSlider.Refresh() end - if pinnedHeightSlider then pinnedHeightSlider.Refresh() end - if pinnedScaleSlider then pinnedScaleSlider.Refresh() end - if pinnedHSpacingSlider then pinnedHSpacingSlider.Refresh() end - if pinnedVSpacingSlider then pinnedVSpacingSlider.Refresh() end - end - - local matchOptions = { party = L["Party"], raid = L["Raid"] } - layoutGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Based on"], matchOptions, "matchMode", function() - UpdateHighlightLayout() - RefreshMatchOverrides() - end), 55) - - -- Width / Height inherit the Match mode's frame size; changing either - -- stores a per-set override (gold star + reset-to-Match), exactly like a - -- layout override but with the Match value as the baseline. - pinnedWidthSlider = CreateMatchOverrideSlider(self.child, L["Width"], 20, 300, 1, "customWidth", "frameWidth", UpdateHighlightLayout) - layoutGroup:AddWidget(pinnedWidthSlider, 55) - pinnedHeightSlider = CreateMatchOverrideSlider(self.child, L["Height"], 10, 200, 1, "customHeight", "frameHeight", UpdateHighlightLayout) - layoutGroup:AddWidget(pinnedHeightSlider, 55) - - -- Scale inherits the Match mode's frameScale; overridable with star/reset. - pinnedScaleSlider = CreateMatchOverrideSlider(self.child, L["Scale"], 0.5, 2.0, 0.1, "scale", "frameScale", UpdateHighlightLayout) - layoutGroup:AddWidget(pinnedScaleSlider, 55) - - -- Per-set Aura / Text Designer preset. "Inherit" (default) follows this mode's - -- preset; pick a named preset to give this set its own aura/text look. Presets - -- are created and edited on the Aura/Text Designer pages — here you only choose - -- which one this pinned set renders with. The Text picker shows whenever the - -- Text Designer module is loaded. - layoutGroup:AddWidget(CreatePinnedPresetDropdown(self.child, L["Aura Designer Template"], "aura", "auraDesignerPreset", RefreshPinnedDisplay), 55) - if DF.TextDesigner then - layoutGroup:AddWidget(CreatePinnedPresetDropdown(self.child, L["Text Designer Template"], "text", "textDesignerPreset", RefreshPinnedDisplay), 55) - end - - -- Border Override (Stage 2b): a single toggle. Off → inherit the Based-on - -- mode's frame border. On → snapshot that border into the set and reveal the - -- full border controls (independent for this set). The proxy delegates - -- reads/writes to the current set so CreateBorderControls tracks tab/mode. - local borderSetProxy = setmetatable({}, { - __index = function(_, k) local s = GetCurrentSet(); return s and s[k] end, - __newindex = function(_, k, v) local s = GetCurrentSet(); if s then s[k] = v end end, - }) - -- Declared first so the border controls' update hooks can refresh the - -- reset icon's visibility after an edit. - local borderResetIcon - local function refreshBorderReset() - if borderResetIcon then - borderResetIcon:SetShown(DF.PinnedFrames and DF.PinnedFrames:IsBorderOverrideChanged(GetCurrentSet()) or false) - end - end - - local borderCheck = CreateRefreshableCheckbox(self.child, L["Override Border"], "borderOverride", function() - if GetCurrentSet().borderOverride and DF.PinnedFrames then - DF.PinnedFrames:SeedSetBorderOverride(GetCurrentSet()) - end - UpdateHighlightLayout() - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end) - -- Reset-to-inherited icon on the row's right, matching the refresh icon the - -- other override controls use. Re-snapshots the border from the Based-on - -- frames (discards edits). Shown only when a border setting actually differs - -- from the inherited value. - do - local rb = GUI:CreateOverrideResetButton(borderCheck, { - tooltip = L["Reset Border to Inherited"], - onClick = function() - if DF.PinnedFrames then DF.PinnedFrames:SeedSetBorderOverride(GetCurrentSet(), true) end - UpdateHighlightLayout() - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }) - rb:SetPoint("TOPRIGHT", borderCheck, "TOPRIGHT", 0, -2) - borderResetIcon = rb - local origRefresh = borderCheck.Refresh - borderCheck.Refresh = function(...) if origRefresh then origRefresh(...) end refreshBorderReset() end - refreshBorderReset() - end - layoutGroup:AddWidget(borderCheck, 28) - GUI:CreateBorderControls(layoutGroup, borderSetProxy, "frame", { - parent = self.child, - include = { - inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, - classColor = true, roleColor = true, - alpha = true, - }, - fullUpdate = function() UpdateHighlightLayout(); refreshBorderReset() end, - lightUpdate = function() UpdateHighlightLayout(); refreshBorderReset() end, - lightColors = function() UpdateHighlightLayout(); refreshBorderReset() end, - refreshStates = function() if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end end, - hideWhen = function() return not (GetCurrentSet() and GetCurrentSet().borderOverride) end, - sizeMin = 1, sizeMax = 16, sizeStep = 1, - }) - - Add(layoutGroup, nil, 1) - layoutGroup.hideOn = function() return activeSubTab ~= "appearance" end -- Appearance tab - - -- ===== LAYOUT GROUP (Column 2) — pinned arrangement. Direction / growth / - -- units-per-row are pinned-only (no main-frame equivalent). Spacing IS a - -- Match override: it inherits the Based-on mode's frameSpacing (grouped) / - -- raidFlat*Spacing (flat) so a pinned set stays aligned with the frames it - -- mirrors, overridable per set. ===== - local arrangeGroup = GUI:CreateSettingsGroup(self.child, 280) - arrangeGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) - - -- Direction: how the pinned frames flow (pinned-only; NOT inherited — the - -- main frames' growDirection means group Rows/Columns, a different concept). - local directionOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } - arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Direction"], directionOptions, "growDirection", UpdateHighlightLayout), 55) - - -- CENTER intentionally omitted: it isn't truly implemented for pinned - -- frames (frames grow START-style; only the anchor/label shift). START/END - -- only for now; a real centred layout can be added later. - local frameAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } - arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Frames Grow From"], frameAnchorOptions, "frameAnchor", UpdateHighlightLayout), 55) - - local columnAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } - arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Columns Grow From"], columnAnchorOptions, "columnAnchor", UpdateHighlightLayout), 55) - - arrangeGroup:AddWidget(CreateRefreshableSlider(self.child, L["Units Per Row"], 1, 10, 1, "unitsPerRow", UpdateHighlightLayout), 55) - -- Spacing inherits the Based-on mode's layout spacing (grouped -> frameSpacing, - -- flat raid -> raidFlat*Spacing); a per-set value overrides it (gold star + reset). - local function SpacingBaseline(flatKey) - return function(mdb) - if mdb and mdb.raidUseGroups == false then return mdb[flatKey] or 2 end - return (mdb and mdb.frameSpacing) or 2 - end - end - pinnedHSpacingSlider = CreateMatchOverrideSlider(self.child, L["Horizontal Spacing"], -5, 50, 1, "horizontalSpacing", SpacingBaseline("raidFlatHorizontalSpacing"), UpdateHighlightLayout) - arrangeGroup:AddWidget(pinnedHSpacingSlider, 55) - pinnedVSpacingSlider = CreateMatchOverrideSlider(self.child, L["Vertical Spacing"], -5, 50, 1, "verticalSpacing", SpacingBaseline("raidFlatVerticalSpacing"), UpdateHighlightLayout) - arrangeGroup:AddWidget(pinnedVSpacingSlider, 55) - Add(arrangeGroup, nil, 2) - arrangeGroup.hideOn = function() return activeSubTab ~= "appearance" end -- Appearance tab - - if not IsCurrentBossMode() then - -- ===== MEMBERS SUB-TAB: Unit Selection (roster) first, then Auto-Populate. - -- Both are "who's in this group", so they lead the Members view; Settings / - -- Frame Type / Frame Style / Layout live on the Appearance sub-tab. ===== - local membersHideOn = function() return activeSubTab ~= "members" end - - -- Unit Selection header with override indicator - unitSelHeader = CreateFrame("Frame", nil, self.child) - unitSelHeader:SetSize(500, 40) - unitSelHeader.hideOn = membersHideOn - local unitSelTitle = unitSelHeader:CreateFontString(nil, "OVERLAY", "DFFontNormal") - unitSelTitle:SetPoint("LEFT", 0, 0) - unitSelTitle:SetText(L["Unit Selection"]) - -- Match the GUI:CreateHeader norm: theme-colored + auto theme listener - -- (every sibling section title uses CreateHeader; replicate it here since - -- this header is composite with a count badge + override indicator). - local _utc = GUI.GetThemeColor() - unitSelTitle:SetTextColor(_utc.r, _utc.g, _utc.b) - unitSelTitle.UpdateTheme = function() - local nc = GUI.GetThemeColor() - unitSelTitle:SetTextColor(nc.r, nc.g, nc.b) - end - if not self.child.ThemeListeners then self.child.ThemeListeners = {} end - table.insert(self.child.ThemeListeners, unitSelTitle) - - -- "N pinned" count beside the title, themed. Updated on any roster change. - local unitSelCount = unitSelHeader:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - unitSelCount:SetPoint("LEFT", unitSelTitle, "RIGHT", 8, 0) - local function UpdateUnitSelCount() - local n = #((GetCurrentSet() and GetCurrentSet().players) or {}) - unitSelCount:SetText(n .. " " .. L["pinned"]) - local tc = GUI.GetThemeColor() - unitSelCount:SetTextColor(tc.r, tc.g, tc.b) - end - UpdateUnitSelCount() - - -- Override indicator for players list (header-level) - AddPinnedOverrideIndicators(unitSelHeader, unitSelTitle, "players", function() - local AutoProfilesUI = DF.AutoProfilesUI - if AutoProfilesUI then - AutoProfilesUI:ResetProfileSetting(GetPinnedKey("players")) - if rosterWidget and rosterWidget.Refresh then rosterWidget:Refresh() end - if DF.PinnedFrames then DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) end - if unitSelHeader.UpdateOverrideIndicators then unitSelHeader:UpdateOverrideIndicators() end - end - end) - unitSelHeader.Refresh = function(self) - if self.UpdateOverrideIndicators then self:UpdateOverrideIndicators() end - UpdateUnitSelCount() - end - - Add(unitSelHeader, 40, "both") - - rosterWidget = GUI:CreateHighlightRosterWidget( - self.child, - function() return GetCurrentSet().players end, - function(players) - local set = GetCurrentSet() - set.players = players - -- Sync manualPlayers: every player currently in the list via GUI is manual. - -- Rebuild the lookup to match exactly what's in the list now. - if not set.manualPlayers then set.manualPlayers = {} end - local newManual = {} - for _, name in ipairs(players) do - -- Preserve existing manual entries, add any new ones - newManual[name] = true - end - set.manualPlayers = newManual - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - -- Deep copy the players array for the override - local copy = {} - for i, v in ipairs(players) do copy[i] = v end - DF.AutoProfilesUI:SetProfileSetting(GetPinnedKey("players"), copy) - if unitSelHeader.UpdateOverrideIndicators then unitSelHeader:UpdateOverrideIndicators() end - end - end, - function() - if DF.PinnedFrames then DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) end - end - ) - - local originalRefresh = rosterWidget.Refresh - rosterWidget.Refresh = function(s) - if originalRefresh then originalRefresh(s) end - if unitSelHeader.UpdateOverrideIndicators then unitSelHeader:UpdateOverrideIndicators() end - UpdateUnitSelCount() - RefreshTabs() -- keep the tab member count in sync with the roster - end - table.insert(controlsToRefresh, rosterWidget) - table.insert(controlsToRefresh, unitSelHeader) - -- The roster widget's content runs to ~364px (panes 240 + role buttons + - -- the "Add Offline Player" input), taller than its 340 frame. Reserve the - -- real height (plus a gap) so the following Auto-Populate group doesn't ride - -- up into the manual-entry row. - Add(rosterWidget, 378, "both") - rosterWidget.hideOn = membersHideOn - - -- ===== AUTO-POPULATE GROUP (full width, under the roster) ===== - local autoPopGroup = GUI:CreateSettingsGroup(self.child, 560) - autoPopGroup:AddWidget(GUI:CreateHeader(self.child, L["Auto-Populate"]), 40) - autoPopGroup:AddWidget(GUI:CreateLabel(self.child, L["Automatically add players by role when they join your group."], 510), 20) - - autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Auto-add Tanks"], "autoAddTanks", function() - if GetCurrentSet().autoAddTanks and DF.PinnedFrames then - DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) - DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) - if rosterWidget then rosterWidget:Refresh() end - SyncPlayersOverride() - end - end), 28) - autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Auto-add Healers"], "autoAddHealers", function() - if GetCurrentSet().autoAddHealers and DF.PinnedFrames then - DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) - DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) - if rosterWidget then rosterWidget:Refresh() end - SyncPlayersOverride() - end - end), 28) - autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Auto-add DPS"], "autoAddDPS", function() - if GetCurrentSet().autoAddDPS and DF.PinnedFrames then - DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) - DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) - if rosterWidget then rosterWidget:Refresh() end - SyncPlayersOverride() - end - end), 28) - -- Exclude Self: keep the player out of this set's auto-add (e.g. Aug Evoker - -- who buffs others). Re-runs auto-populate so self is added/removed live. - autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Exclude Self"], "excludeSelf", function() - if DF.PinnedFrames then - DF.PinnedFrames:AutoPopulateSet(GetCurrentSet()) - DF.PinnedFrames:UpdateHeaderNameList(activeHighlightTab) - if rosterWidget then rosterWidget:Refresh() end - SyncPlayersOverride() - end - end), 28) - autoPopGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Keep when offline/left"], "keepOfflinePlayers", function() end, L["Keep when offline/left Tooltip"]), 28) - - Add(autoPopGroup, nil, "both") - autoPopGroup.hideOn = membersHideOn - end -- not IsCurrentBossMode - - RefreshControls() - - -- Show preview containers if editing a non-active mode - -- (e.g. raid settings while actually in a party): lets the user - -- position/scale the pinned frames for that mode without being in it. - if DF.PinnedFrames then - DF.PinnedFrames:ShowPreview(GUI.SelectedMode) - end - end) - - pagePinnedFrames:SetScript("OnHide", function() - if DF.PinnedFrames then - DF.PinnedFrames:HidePreview() - end - end) - - -- General > Sorting - local pageSorting = CreateSubTab("general", "general_sorting", L["Sorting"]) - BuildPage(pageSorting, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - -- "useFrameSort" is the FrameSort integration toggle — a real per-mode key - -- that no prefix reached. (selfPosition / rolePriority / classPriority match - -- nothing: those settings are stored as sort*, which the first prefix covers. - -- Harmless, kept so the intent of the list stays readable.) - Add(CreateCopyButton(self.child, {"sort", "useFrameSort", "selfPosition", "rolePriority", "classPriority"}, L["Sorting"], "general_sorting"), 25, 2) - - -- Helper function to trigger sort for current mode - local function TriggerSortForCurrentMode() - if DF.testMode or DF.raidTestMode then - if DF.RefreshTestFramesWithLayout then DF:RefreshTestFramesWithLayout() end - return - end - if DF.headersInitialized then DF:ApplyHeaderSettings() end - -- Arena: ApplyHeaderSettings handles orientation but not sorting. - -- Call ApplyArenaHeaderSorting directly for settings changes from the GUI. - if DF.IsInArena and DF:IsInArena() then - if not InCombatLockdown() and DF.ApplyArenaHeaderSorting then - DF:ApplyArenaHeaderSorting() - end - elseif GUI.SelectedMode == "raid" then - if DF.SecureSort then - DF.SecureSort:PushRaidSortSettings() - DF.SecureSort:TriggerSecureRaidSort() - end - else - if DF.Sort then DF.Sort:TriggerResort() end - end - end - - -- When FrameSort takes over ordering, DF's own sort options are - -- irrelevant and HIDE (variant gate). Otherwise they GREY OUT - -- (disabled-in-place) while custom sorting is off rather than vanishing. - local function HideSortOptions(d) - return d.useFrameSort and FrameSortApi - end - local function DisableSortOptions(d) - return not d.sortEnabled - end - - -- Store reference to role widget so we can refresh it - local roleOrderWidget = nil - - -- ===== COMBAT STATUS BANNER (full width) ===== - local combatBanner = GUI:CreateInfoBanner(self.child, { fontTemplate = "DFFontNormal" }) - - local function UpdateCombatBanner() - if not db.sortEnabled then - combatBanner:Hide() - return - end - combatBanner:Show() - - local selfPos = db.sortSelfPosition or "SORTED" - local hasAdvancedOptions = db.sortSeparateMeleeRanged or db.sortByClass or db.sortAlphabetical - - if hasAdvancedOptions then - combatBanner:SetTone("danger") - combatBanner:SetText(L["Combat Limitation: All groups will not update with new players that join mid-combat."]) - elseif selfPos == "FIRST" or selfPos == "LAST" then - combatBanner:SetTone("caution") - -- Override default warning icon with info icon for this softer state. - combatBanner:SetIconTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\info") - combatBanner:SetText(L["Combat Limitation: Your group will not update with new players that join mid-combat."]) - else - combatBanner:SetTone("success") - combatBanner:SetText(L["Fully Combat Safe: Frames will update normally during combat."]) - end - end - - -- Hide when an external FrameSort addon owns sorting OR our sorting is off - -- (the combat banner is only meaningful while our sorting is enabled). - -- Without the sortEnabled check, the page's RefreshStates re-showed the - -- banner after UpdateCombatBanner had hidden it -- a white, tone-less box. - combatBanner.hideOn = function(d) return HideSortOptions(d) or not d.sortEnabled end - -- Reapply tone + text on every page refresh. RefreshStates calls - -- refreshContent (not the old custom UpdateBanner method), so the banner - -- never shows without a tone (the backdrop defaults to white until toned). - combatBanner.refreshContent = UpdateCombatBanner - Add(combatBanner, combatBanner.layoutHeight, "both") - - -- Initial update - UpdateCombatBanner() - - -- ===== SORTING OPTIONS GROUP (Column 1) ===== - local sortOptionsGroup = GUI:CreateSettingsGroup(self.child, 280) - sortOptionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Unit Frame Sorting"]), 40) - sortOptionsGroup:AddWidget(GUI:CreateLabel(self.child, L["Sort party members by role, class, and name.\n\nSort order: Self Position > Role > Class > Name"], 250), 60) - - local raidSortNote = sortOptionsGroup:AddWidget(GUI:CreateLabel(self.child, L["Raid: Group layout sorts within each group.\nFlat grid layout sorts all players together."], 250), 35) - raidSortNote.hideOn = function() return GUI.SelectedMode ~= "raid" end - - local sortEnable = sortOptionsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Custom Sorting"], db, "sortEnabled", function() - TriggerSortForCurrentMode() - UpdateCombatBanner() - self:RefreshStates() - end), 30) - sortEnable.keepEnabled = true - sortOptionsGroup.disableChildrenOn = DisableSortOptions - - local sortMeleeRanged = sortOptionsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Separate Melee & Ranged DPS"], db, "sortSeparateMeleeRanged", function() - TriggerSortForCurrentMode() - if roleOrderWidget and roleOrderWidget.Refresh then roleOrderWidget.Refresh() end - UpdateCombatBanner() - end), 30) - sortMeleeRanged.hideOn = HideSortOptions - - local sortByClass = sortOptionsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Sort by Class (within role)"], db, "sortByClass", function() - TriggerSortForCurrentMode() - self:RefreshStates() - UpdateCombatBanner() - end), 30) - sortByClass.hideOn = HideSortOptions - - local sortAlphaValues = { - [false] = L["Off"], - ["AZ"] = L["A to Z"], - ["ZA"] = L["Z to A"], - _order = {false, "AZ", "ZA"}, - } - local sortAlpha = sortOptionsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alphabetical (within class/role)"], sortAlphaValues, db, "sortAlphabetical", function() - TriggerSortForCurrentMode() - UpdateCombatBanner() - end), 55) - sortAlpha.hideOn = HideSortOptions - - Add(sortOptionsGroup, nil, 1) - - -- ===== FRAMESORT INTEGRATION GROUP (Column 1) ===== - if FrameSortApi then - local frameSortGroup = GUI:CreateSettingsGroup(self.child, 280) - frameSortGroup:AddWidget(GUI:CreateHeader(self.child, L["FrameSort Integration"]), 40) - frameSortGroup:AddWidget(GUI:CreateLabel(self.child, format(L["FrameSort addon detected. Enable to let FrameSort control frame ordering.\n\n%sExperimental:%s This feature is new and may not work perfectly in all scenarios. Please report any issues."], "|c" .. GUI:ToneHex("caution"), "|r"), 250), 70) - frameSortGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use FrameSort Addon"], db, "useFrameSort", function() - -- Set both modes simultaneously - local partyDB = DF:GetDB("party") - local raidDB = DF:GetDB("raid") - if partyDB then partyDB.useFrameSort = db.useFrameSort end - if raidDB then raidDB.useFrameSort = db.useFrameSort end - -- Notify the FrameSort module - if DF.FrameSort and DF.FrameSort.OnSettingChanged then - DF.FrameSort:OnSettingChanged() - end - -- Trigger a re-sort so the change takes effect immediately - TriggerSortForCurrentMode() - -- Refresh options visibility - self:RefreshStates() - end), 30) - Add(frameSortGroup, nil, 1) - end - - -- ===== SELF POSITION GROUP (Column 1) ===== - local selfPosGroup = GUI:CreateSettingsGroup(self.child, 280) - selfPosGroup:AddWidget(GUI:CreateHeader(self.child, L["Self Position"]), 40) - - local selfPosValues = { - ["FIRST"] = L["Always First"], - ["LAST"] = L["Always Last"], - ["SORTED"] = L["Sorted with Group"], - ["NORMAL"] = L["Sorted with Group"], - _order = {"FIRST", "LAST", "SORTED"}, - } - selfPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], selfPosValues, db, "sortSelfPosition", function() - TriggerSortForCurrentMode() - UpdateCombatBanner() - end), 55) - selfPosGroup.hideOn = HideSortOptions - selfPosGroup.disableChildrenOn = DisableSortOptions - Add(selfPosGroup, nil, 1) - - -- ===== ROLE PRIORITY GROUP (Column 2) ===== - local rolePriorityGroup = GUI:CreateSettingsGroup(self.child, 280) - rolePriorityGroup:AddWidget(GUI:CreateHeader(self.child, L["Role Priority"]), 40) - rolePriorityGroup:AddWidget(GUI:CreateLabel(self.child, L["Drag to reorder. Top = first."], 250), 25) - - roleOrderWidget = GUI:CreateRoleOrderList(self.child, db, "sortRoleOrder", function() - TriggerSortForCurrentMode() - end, "sortSeparateMeleeRanged") - rolePriorityGroup:AddWidget(roleOrderWidget, 135) - rolePriorityGroup.hideOn = HideSortOptions - rolePriorityGroup.disableChildrenOn = DisableSortOptions - Add(rolePriorityGroup, nil, 2) - - -- ===== CLASS PRIORITY GROUP (Column 2) ===== - local classPriorityGroup = GUI:CreateSettingsGroup(self.child, 280) - classPriorityGroup:AddWidget(GUI:CreateHeader(self.child, L["Class Priority"]), 40) - classPriorityGroup:AddWidget(GUI:CreateLabel(self.child, L["Drag to reorder. Top = first."], 250), 25) - - local classOrderWidget = GUI:CreateClassOrderList(self.child, db, "sortClassOrder", function() - TriggerSortForCurrentMode() - end) - classPriorityGroup:AddWidget(classOrderWidget, 320) - -- Hide under FrameSort takeover or when not sorting by class (variant - -- gates); grey out when custom sorting is disabled (enable gate). - classPriorityGroup.hideOn = function(d) return (d.useFrameSort and FrameSortApi) or not d.sortByClass end - classPriorityGroup.disableChildrenOn = DisableSortOptions - Add(classPriorityGroup, nil, 2) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "general_frame", label = L["Frame"]}, - {pageId = "general_labels", label = L["Group Labels"]}, - }), 30, "both") - end) - - -- General > Nicknames (account-wide list; custom builder, no party/raid switch) - local pageNicknames = CreateSubTab("general", "general_nicknames", L["Nicknames"]) - BuildPage(pageNicknames, function(self, db, Add, AddSpace, AddSyncPoint) - if DF.BuildNicknamesPage then - DF.BuildNicknamesPage(GUI, self, db, Add, AddSpace) - end - end) - - -- General > Integrations - local pageIntegrations = CreateSubTab("general", "general_integrations", L["Integrations"]) - BuildPage(pageIntegrations, function(self, db, Add, AddSpace, AddSyncPoint) - Add(CreateCopyButton(self.child, {"colorPicker"}, L["Integrations"], "general_integrations"), 25, 2) - -- ===== COLOR PICKER GROUP (Column 1) ===== - local colorPickerGroup = GUI:CreateSettingsGroup(self.child, 280) - colorPickerGroup:AddWidget(GUI:CreateHeader(self.child, L["Color Picker"]), 40) - - colorPickerGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use DF Color Picker"], db, "colorPickerOverride", function() - if db.colorPickerOverride or db.colorPickerGlobalOverride then - GUI:InstallColorPickerHook() - else - GUI:UninstallColorPickerHook() - end - if db.colorPickerOverride then - DF:Say("Color picker override enabled") - else - DF:Say("Color picker override disabled", nil, "WARN") - end - end), 30) - colorPickerGroup:AddWidget(GUI:CreateLabel(self.child, L["Replace Blizzard's color picker with the DandersFrames color picker for this addon."], 250), 40) - - colorPickerGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use DF Color Picker for All Addons"], db, "colorPickerGlobalOverride", function() - if db.colorPickerOverride or db.colorPickerGlobalOverride then - GUI:InstallColorPickerHook() - else - GUI:UninstallColorPickerHook() - end - if db.colorPickerGlobalOverride then - DF:Say("Custom color picker enabled for all addons") - else - DF:Say("Custom color picker disabled for all addons", nil, "WARN") - end - end), 30) - colorPickerGroup:AddWidget(GUI:CreateLabel(self.child, L["Show the DF color picker when any addon opens a color picker."], 250), 30) - - Add(colorPickerGroup, nil, 1) - - -- (Masque Integration group removed on 12.1: Masque cannot skin the - -- native container aura buttons — its script hooks and backdrops are - -- blocked on the protected buttons — so the toggle had no effect.) - - -- (Click-Through Icons group removed on 12.1: the container aura buttons - -- are always click-through by design — Blizzard's AlwaysPropagateInput + - -- the factory's unconditional SetMouseClickEnabled(false) — so the toggle - -- had no effect. Tooltips are governed by the Tooltips page instead.) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_buffs", label = L["Buffs"]}, - {pageId = "auras_debuffs", label = L["Debuffs"]}, - {pageId = "auras_defensiveicon", label = L["Defensive Icon"]}, - -- DEPRECATED-TARGETED-SPELLS: link dropped with the sidebar row. - }), 30, "both") - end) - - -- Display > Colors (was "Class Colors" pre-Stage 2; renamed when role - -- colours moved here so the page houses BOTH the class set and the role - -- set used by every border that opts into include.classColor / - -- include.roleColor in CreateBorderControls.) - local pageColors = CreateSubTab("display", "display_classcolors", L["Colors"]) - BuildPage(pageColors, function(self, db, Add, AddSpace, AddSyncPoint) - -- Class colors are shared across party/raid, stored at profile level - local classColorsDB = DF.db.classColors - if not classColorsDB then - DF.db.classColors = {} - classColorsDB = DF.db.classColors - end - - -- Role colors live at profile level too (DF.db.roleColors), seeded by - -- DF:MigrateRoleBorderColors on db load. - local roleColorsDB = DF.db.roleColors - if not roleColorsDB then - DF.db.roleColors = {} - roleColorsDB = DF.db.roleColors - end - - -- Ordered list of all classes with display names - local CLASS_LIST = { - { token = "WARRIOR", name = L["Warrior"] }, - { token = "PALADIN", name = L["Paladin"] }, - { token = "HUNTER", name = L["Hunter"] }, - { token = "ROGUE", name = L["Rogue"] }, - { token = "PRIEST", name = L["Priest"] }, - { token = "DEATHKNIGHT", name = L["Death Knight"] }, - { token = "SHAMAN", name = L["Shaman"] }, - { token = "MAGE", name = L["Mage"] }, - { token = "WARLOCK", name = L["Warlock"] }, - { token = "MONK", name = L["Monk"] }, - { token = "DRUID", name = L["Druid"] }, - { token = "DEMONHUNTER", name = L["Demon Hunter"] }, - { token = "EVOKER", name = L["Evoker"] }, - } - - local ROLE_LIST = { - { token = "TANK", name = L["Tank"] }, - { token = "HEALER", name = L["Healer"] }, - { token = "DAMAGER", name = L["Damager"] }, - } - local ROLE_DEFAULTS = { - TANK = {r = 0.20, g = 0.55, b = 0.95, a = 1}, - HEALER = {r = 0.20, g = 0.80, b = 0.30, a = 1}, - DAMAGER = {r = 0.85, g = 0.20, b = 0.20, a = 1}, - } - - -- ===== Column 1 ===== - local col1 = GUI:CreateSettingsGroup(self.child, 280) - col1:AddWidget(GUI:CreateHeader(self.child, L["Class Colors"]), 40) - col1:AddWidget(GUI:CreateLabel(self.child, L["Customize class colors used throughout DandersFrames. Changes apply to health bars, name text, borders, and all other class-colored elements."], 260), 50) - - -- Reset All button - local resetAllBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") - GUI:StyleButton(resetAllBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) - resetAllBtn:SetScript("OnClick", function() - -- Reset all to Blizzard defaults - for _, info in ipairs(CLASS_LIST) do - local default = RAID_CLASS_COLORS[info.token] - if default then - classColorsDB[info.token] = { r = default.r, g = default.g, b = default.b, a = 1 } - end - end - DF:RefreshAllVisibleFrames() - -- Refresh the options page to update swatches - if pageColors and pageColors.Refresh then - pageColors:Refresh() - end - end) - col1:AddWidget(resetAllBtn, 30) - - -- All classes in a single section - for i = 1, #CLASS_LIST do - local info = CLASS_LIST[i] - local token = info.token - -- Initialize from Blizzard defaults if not customized - if not classColorsDB[token] then - local default = RAID_CLASS_COLORS[token] - if default then - classColorsDB[token] = { r = default.r, g = default.g, b = default.b, a = 1 } - end - end - col1:AddWidget(GUI:CreateColorPicker(self.child, info.name, classColorsDB, token, false, function() - DF:RefreshAllVisibleFrames() - end, function() - DF:RefreshAllVisibleFrames() - end, true), 30) - end - - Add(col1, nil, 1) - - -- ===== Column 2: Role Colors ===== - local col2 = GUI:CreateSettingsGroup(self.child, 280) - col2:AddWidget(GUI:CreateHeader(self.child, L["Role Colors"]), 40) - col2:AddWidget(GUI:CreateLabel(self.child, L["Customize role colors used by any border whose Color Source is set to Role. Applies to Tank, Healer, and Damager assignments."], 260), 50) - - local roleResetBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") - GUI:StyleButton(roleResetBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) - roleResetBtn:SetScript("OnClick", function() - for _, info in ipairs(ROLE_LIST) do - local d = ROLE_DEFAULTS[info.token] - if d then roleColorsDB[info.token] = { r = d.r, g = d.g, b = d.b, a = d.a } end - end - DF:RefreshAllVisibleFrames() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end) - col2:AddWidget(roleResetBtn, 30) - - for i = 1, #ROLE_LIST do - local info = ROLE_LIST[i] - if not roleColorsDB[info.token] then - local d = ROLE_DEFAULTS[info.token] - if d then roleColorsDB[info.token] = { r = d.r, g = d.g, b = d.b, a = d.a } end - end - col2:AddWidget(GUI:CreateColorPicker(self.child, info.name, roleColorsDB, info.token, false, function() - DF:RefreshAllVisibleFrames() - end, function() - DF:RefreshAllVisibleFrames() - end, true), 30) - end - - Add(col2, nil, 2) - - -- ===== Column 1 (cont.): Dispel Colours ===== - -- Account-wide per-dispel-type palette (DF.db.dispelColors), the single source of - -- truth for both the debuff-icon border and the dispel overlay. Defaults ARE the - -- game palette (GetGameDispelPalette, queried from AuraUtil), so an untouched - -- palette matches the game exactly; the overlay always follows it, the icon when - -- "Color by Dispel Type" is on. No None/Physical picker — that border is hidden on - -- no-dispel-type auras and the overlay never fires on them. Editing re-drives frames. - local dispelColorsDB = DF.db.dispelColors - if type(dispelColorsDB) ~= "table" then - DF.db.dispelColors = {} - dispelColorsDB = DF.db.dispelColors - end - local dispelGamePalette = DF:GetGameDispelPalette() - local DISPEL_LIST = { - { key = "Magic", name = L["Magic"] }, - { key = "Curse", name = L["Curse"] }, - { key = "Disease", name = L["Disease"] }, - { key = "Poison", name = L["Poison"] }, - { key = "Bleed", name = L["Bleed / Enrage"] }, - } - -- Commit: bump the curve generation, rebuild the container rows (fresh - -- SetAuraBorder binds pick up the new curve) and restyle the overlay - -- (its binds re-run via the generation gate). - local function DispelColorChanged() - if DF.InvalidateDispelColorCurve then DF:InvalidateDispelColorCurve() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - if DF.UpdateAllFrames then DF:UpdateAllFrames() end - if DF.LightweightUpdateDispelOverlay then DF:LightweightUpdateDispelOverlay() end - end - -- Live (colour-wheel drag): cheap path — the overlay re-binds via the - -- generation gate and test-mode rings repaint on their ticker; live row - -- rings catch up on commit (a native bind can't retint per drag frame). - local function DispelColorLive() - if DF.InvalidateDispelColorCurve then DF:InvalidateDispelColorCurve() end - if DF.LightweightUpdateDispelOverlay then DF:LightweightUpdateDispelOverlay() end - end - local dispelCol = GUI:CreateSettingsGroup(self.child, 280) - dispelCol:AddWidget(GUI:CreateHeader(self.child, L["Dispel Type Colors"]), 40) - dispelCol:AddWidget(GUI:CreateLabel(self.child, L["Colours for each dispel type, used by the dispel overlay and the debuff-icon border (when Color by Dispel Type is on). Reset restores the game's colours."], 260), 55) - local dispelResetBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") - GUI:StyleButton(dispelResetBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) - dispelResetBtn:SetScript("OnClick", function() - for _, info in ipairs(DISPEL_LIST) do - local d = dispelGamePalette[info.key] - if d then dispelColorsDB[info.key] = { r = d.r, g = d.g, b = d.b } end - end - DispelColorChanged() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end) - dispelCol:AddWidget(dispelResetBtn, 30) - for i = 1, #DISPEL_LIST do - local info = DISPEL_LIST[i] - if type(dispelColorsDB[info.key]) ~= "table" then - local d = dispelGamePalette[info.key] - if d then dispelColorsDB[info.key] = { r = d.r, g = d.g, b = d.b } end - end - dispelCol:AddWidget(GUI:CreateColorPicker(self.child, info.name, dispelColorsDB, info.key, false, DispelColorChanged, DispelColorLive, true), 30) - end - Add(dispelCol, nil, 1) - - -- ===== Column 2 (cont.): Color by Time Remaining ===== - -- Account-wide duration-colour breakpoints, shared by the buff / debuff / defensive - -- rows AND the Aura Designer wherever "Color by Time Remaining" is enabled. Each stop - -- colours the duration text from its threshold upward; the highest threshold at or below - -- the time left wins. Editing here re-drives the aura formatters live (no /reload). - -- - -- Hybrid editor: a read-only preview strip (low time on the left, high on the right) - -- over one row per stop. Each row reuses CreateColorPicker with its LABEL set to the - -- human range it covers ("8s and above", "5-8s", "under 2s"), and a small +/- stepper - -- moves that stop's lower boundary. Colour edits re-tint the strip in place; boundary - -- add/remove/reset rebuild the page so every range label and the strip stay in sync. - -- TWO SECTIONS, one per consumer: duration TEXT and the expiry BORDER/TINT reveal. - -- An aura page only carries an on/off; everything about HOW the colour is read - -- lives here with the colours it reads, because that is a property of the ramp. - -- TEXT blends or steps (12.1's colour curve), on either scale. - -- BORDER steps only — its colours are baked into |T inline-texture escapes, - -- which ignore the fontstring vertex colour a curve writes. - -- Each section keeps ONE stop list PER SCALE: thresholds cannot be reinterpreted - -- between units (8 seconds is not 8 percent), so switching the scale swaps which - -- ramp is being edited and leaves the other untouched for switching back. - local cbtGlobalDB = DF:GetGlobalDB() - local iconPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - - local CBT_UNITS = { - SECONDS = { maxT = nil, capT = 600, -- seconds have no natural ceiling; derive from the stops - above = L["%ds and above"], under = L["under %ds"], range = L["%d-%ds"] }, - PERCENT = { maxT = 100, capT = 100, - above = L["%d%% and above"], under = L["under %d%%"], range = L["%d-%d%%"] }, - } - -- ONE RAMP PER UNIT, shared by every colour-by-time consumer — the duration text - -- AND the expiry border/tint reveal. "Time is running out" is one idea, so it gets - -- one set of colours. BOTH ramps are live at once: duration text reads whichever - -- its s/% toggle names (it has no threshold, so that is account-wide), while each - -- expiry reveal reads the one matching ITS OWN unit — per indicator, because a - -- reveal's bands and its Alert Below threshold are one formatter sampled against - -- one property. - local CBT_RAMPS = { - SECONDS = { bpKey = "durationColorByTimeBreakpoints" }, - PERCENT = { bpKey = "durationColorByPercentBreakpoints" }, - } - - local function ApplyColorByTime() - if DF.InvalidateDurationFormatters then DF:InvalidateDurationFormatters() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - if DF.UpdateAllFrames then DF:UpdateAllFrames() end - if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - -- Expiry reveals read these ramps too, so an open Aura Designer card must - -- re-render its preview (no-ops if the designer was never opened). - if DF.AuraDesigner_RefreshPage then DF:AuraDesigner_RefreshPage() end - end - - -- The colour swatch fires its callback on EVERY change (many ticks per wheel drag), - -- so debounce the heavy re-drive: only the settled colour rebuilds the containers. - -- (add/remove/reset call ApplyColorByTime directly — discrete, no debounce needed.) - local cbtApplyTimer - local function ScheduleColorApply() - if cbtApplyTimer then cbtApplyTimer:Cancel() end - cbtApplyTimer = C_Timer.NewTimer(0.2, function() - cbtApplyTimer = nil - ApplyColorByTime() - end) - end - - -- Page-level collapsible section wrapping BOTH boxes, matching Health Bar and - -- friends. Its title is also the cross-link anchor every aura page flashes - -- (GUI:CreateColorsPageLink -> LinkToSetting{ section = L["Color by Time"] }) — - -- keep the two in step if it is ever renamed. Assigned just before the build - -- loop so it lands on the page ABOVE the boxes it owns. - local cbtSection - - -- ONE box (design "A"): Seconds/Percent TABS pick which unit's stops are being - -- EDITED (self._cbtEditUnit — page-local UI state, deliberately not saved), the - -- editor strip under them always shows HARD BANDS (the stops are data; how a - -- consumer renders them is the legend's job), and the "How this renders" legend - -- at the bottom is FIXED: a labelled preview per consumer, with that consumer's - -- own dials beside it. The previous layout drew the gradient on whichever ramp - -- the text happened to read, so flipping the text's unit visibly moved the - -- gradient between boxes — correct data, but it read as a bug. - local function BuildSection() - local editUnit = (self._cbtEditUnit == "PERCENT") and "PERCENT" or "SECONDS" - self._cbtEditUnit = editUnit - local scale = CBT_UNITS[editUnit] - local bpKey = CBT_RAMPS[editUnit].bpKey - - -- Seed BOTH ramps: the legend previews can show the non-edited unit (the text - -- preview always renders the ramp the text actually reads), so both lists must - -- exist whichever tab is up. - for _, ramp in pairs(CBT_RAMPS) do - if type(cbtGlobalDB[ramp.bpKey]) ~= "table" or #cbtGlobalDB[ramp.bpKey] == 0 then - cbtGlobalDB[ramp.bpKey] = DF:DeepCopy(DF.GlobalDefaults[ramp.bpKey]) - end - end - local bps = cbtGlobalDB[bpKey] - - local function cbtT(s) return math.max(0, tonumber(s and s.threshold) or 0) end - local function cbtSorted(descending) - local out = {} - for _, s in ipairs(bps) do out[#out + 1] = s end - table.sort(out, function(a, b) - if descending then return cbtT(a) > cbtT(b) else return cbtT(a) < cbtT(b) end - end) - return out - end - - local cbtGroup = GUI:CreateSettingsGroup(self.child, 280) - - -- Tabs: which unit's stops are on the editor below. UI state only — flipping a - -- tab changes nothing about what renders in the world. Underline-tab style - -- (StyleButton opts.tab — the PARTY/RAID/BINDS look), half-width each so the - -- pair spans the box. No explicit accent: the tab picks up the mode accent - -- (party purple / raid), same as the main tabs. CreateSegmentToggle stays the - -- compact value toggle beside a control (the s/% dials in the legend below). - local tabRow = CreateFrame("Frame", nil, self.child) - tabRow:SetSize(260, 24) - local prevTab - for _, def in ipairs({ - { key = "SECONDS", label = L["Seconds"] }, - { key = "PERCENT", label = L["Percent"] }, - }) do - local tabBtn = CreateFrame("Button", nil, tabRow, "BackdropTemplate") - GUI:StyleButton(tabBtn, { tab = true, width = 128, height = 24, text = def.label, font = "DFFontHighlight" }) - if prevTab then - tabBtn:SetPoint("LEFT", prevTab, "RIGHT", 4, 0) - else - tabBtn:SetPoint("LEFT", tabRow, "LEFT", 0, 0) - end - local key = def.key - tabBtn:SetScript("OnClick", function() - if self._cbtEditUnit ~= key then - self._cbtEditUnit = key - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end - end) - tabBtn:SetActive(editUnit == key) - prevTab = tabBtn - end - cbtGroup:AddWidget(tabRow, 30) - - -- Strip builder, shared by the editor strip and the legend previews. `unit` picks - -- which ramp the strip shows — the editor and border previews show the EDITED - -- unit, the text preview always shows the ramp the TEXT reads. smoothMode marks - -- the strips that render the way the duration text does (gradient while Blend - -- Colors Smoothly is on). Low values left, high right. - local previewW, stripH = 256, 18 - local strips = {} - local function BuildStrip(w, h, smoothMode, unit) - local f = CreateFrame("Frame", nil, self.child) - f:SetSize(w, h) - f.segs, f.smoothMode = {}, smoothMode - local asc = {} - for _, s2 in ipairs(cbtGlobalDB[CBT_RAMPS[unit].bpKey]) do asc[#asc + 1] = s2 end - table.sort(asc, function(a, b) return cbtT(a) < cbtT(b) end) - local maxT = CBT_UNITS[unit].maxT or math.max(12, cbtT(asc[#asc]) + 2) - for k = 1, #asc do - local lo = cbtT(asc[k]) - local hi = (k < #asc) and cbtT(asc[k + 1]) or maxT - if hi > lo then - local tex = f:CreateTexture(nil, "ARTWORK") - tex:SetPoint("TOPLEFT", f, "TOPLEFT", (lo / maxT) * w, 0) - tex:SetSize(((hi - lo) / maxT) * w, h) - tex:SetColorTexture(1, 1, 1, 1) -- white base; the gradient tints it - f.segs[#f.segs + 1] = { tex = tex, from = asc[k], to = asc[k + 1] } - end - end - strips[#strips + 1] = f - return f - end - local function RefreshPreview() - for _, f in ipairs(strips) do - local sm = f.smoothMode and (cbtGlobalDB.durationTextColorSmooth ~= false) - for _, seg in ipairs(f.segs) do - -- ONE path for both modes: reset the base to white, then gradient - -- a->b — stepped is just a->a, a solid band. The mode now flips at - -- RUNTIME (the Blend checkbox retints in place), and SetGradient - -- layered over a texture left as SetColorTexture(band colour) - -- MULTIPLIES the two into mud; the white reset makes every retint - -- idempotent. The band above the final stop stays flat because the - -- curve clamps there (probe-verified). - local a = seg.from.color or { r = 1, g = 1, b = 1 } - local b = (sm and seg.to and seg.to.color) or a - seg.tex:SetColorTexture(1, 1, 1, 1) - seg.tex:SetGradient("HORIZONTAL", - CreateColor(a.r or 1, a.g or 1, a.b or 1, 1), - CreateColor(b.r or 1, b.g or 1, b.b or 1, 1)) - end - end - end - - -- Editor strip: ALWAYS hard bands. The stops are the data being edited; whether a - -- consumer blends them is that consumer's property, previewed in the legend below. - cbtGroup:AddWidget(BuildStrip(previewW, stripH, false, editUnit), 24) - RefreshPreview() - - -- ONE row per stop, freshest (highest threshold) first: colour chip, computed - -- range, boundary stepper, remove. The chip IS a CreateColorPicker — shrunk to - -- its swatch by resizing the container (the button anchors TOPLEFT/TOPRIGHT, so - -- it follows) — which brings the whole dialog stack along (cancel restore, - -- Default button, ElvUI hook, spurious open-fire suppression) instead of - -- reimplementing any of it. The "Starts at" caption the merge dropped lives on - -- as a tooltip on the stepper controls. - local desc = cbtSorted(true) - for i = 1, #desc do - local bp = desc[i] - if type(bp.color) ~= "table" then bp.color = { r = 1, g = 1, b = 1 } end - local t = cbtT(bp) - local above = (i > 1) and cbtT(desc[i - 1]) or nil - local rangeLabel - if i == 1 then - rangeLabel = format(scale.above, t) - elseif t == 0 then - rangeLabel = format(scale.under, above or 0) - else - rangeLabel = format(scale.range, t, above or t) - end - - local row = CreateFrame("Frame", nil, self.child) - row:SetSize(previewW, 24) - - local chip = GUI:CreateColorPicker(self.child, "", bp, "color", false, function() - RefreshPreview() - ScheduleColorApply() - end, nil, false) - chip:SetParent(row) - chip:SetSize(52, 24) - chip:SetPoint("LEFT", row, "LEFT", 0, 0) - - local cap = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - cap:SetPoint("LEFT", chip, "RIGHT", 8, 0) - cap:SetText(rangeLabel) - cap:SetTextColor(0.85, 0.85, 0.85) - cap:SetWordWrap(false) - cap:SetJustifyH("LEFT") - - -- Boundary stepper (the t == 0 base band has no adjustable lower edge). - -- +/- nudge by one; the middle field is typeable for big jumps (8 -> 120). - if t ~= 0 then - local lowerBound = (i < #desc) and (cbtT(desc[i + 1]) + 1) or 1 - -- The top stop is capped by the scale (100% has a real ceiling; seconds - -- keep the long-buff headroom the field already allowed). - local upperBound = (i > 1) and (cbtT(desc[i - 1]) - 1) or scale.capT - - local eb - local function commitTo(nt) - nt = math.floor(tonumber(nt) or cbtT(bp)) - nt = math.max(lowerBound, math.min(upperBound, nt)) - if nt == cbtT(bp) then - if eb then eb:SetText(tostring(cbtT(bp))) end - return - end - bp.threshold = nt - ApplyColorByTime() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end - - -- Right-to-left: [−][value][+][×], the × only when removable. Stepper - -- rows all share the removable state, so their columns stay aligned. - local rightAnchor, rightPoint, rightOff = row, "RIGHT", -2 - if #bps > 2 then - -- Reuse the shared close-X in its default (dismiss) form — grey glyph at - -- rest, white glyph + red hover wash on mouseover, exactly like the GUI's - -- own close button. (No tone="danger" — that tints the glyph red at rest.) - local remBtn = GUI:CreateCloseButton(row, { - size = 20, - onClick = function() - for idx, s in ipairs(bps) do - if s == bp then table.remove(bps, idx) break end - end - ApplyColorByTime() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end, - }) - remBtn:SetPoint("RIGHT", row, "RIGHT", -2, 0) - rightAnchor, rightPoint, rightOff = remBtn, "LEFT", -6 - end - - local plus = CreateFrame("Button", nil, row, "BackdropTemplate") - GUI:StyleButton(plus, { width = 22, height = 20, icon = { texture = iconPath .. "add", size = 12, color = { r = 0.85, g = 0.85, b = 0.85 } } }) - plus:SetPoint("RIGHT", rightAnchor, rightPoint, rightOff, 0) - plus:SetScript("OnClick", function() commitTo(cbtT(bp) + 1) end) - - eb = CreateFrame("EditBox", nil, row) - GUI:StyleEditBox(eb) - eb:SetSize(42, 20) - eb:SetPoint("RIGHT", plus, "LEFT", -4, 0) - eb:SetAutoFocus(false) - eb:SetNumeric(true) - eb:SetMaxLetters(4) - eb:SetJustifyH("CENTER") - eb:SetText(tostring(t)) - eb:SetCursorPosition(0) - eb:SetScript("OnEnterPressed", function(self) - local text = self:GetText() - self:ClearFocus() - commitTo(text) - end) - eb:SetScript("OnEscapePressed", function(self) - self:SetText(tostring(cbtT(bp))) - self:ClearFocus() - end) - eb:SetScript("OnEditFocusLost", function(self) - self:SetText(tostring(cbtT(bp))) - end) - - local minus = CreateFrame("Button", nil, row, "BackdropTemplate") - GUI:StyleButton(minus, { width = 22, height = 20, icon = { texture = iconPath .. "remove", size = 12, color = { r = 0.85, g = 0.85, b = 0.85 } } }) - minus:SetPoint("RIGHT", eb, "LEFT", -4, 0) - minus:SetScript("OnClick", function() commitTo(cbtT(bp) - 1) end) - - -- The merged row dropped the "Starts at" caption; the value box (only — - -- the +/- explain themselves) says it on hover instead. - eb:HookScript("OnEnter", function() GUI:ShowTooltip(eb, { title = L["Starts at"] }) end) - eb:HookScript("OnLeave", function() GUI:HideTooltip() end) - cap:SetPoint("RIGHT", minus, "LEFT", -6, 0) - else - cap:SetPoint("RIGHT", row, "RIGHT", -6, 0) - end - cbtGroup:AddWidget(row, 28) - end - - -- Add + Reset side by side: neither needs a full row to itself, and halving - -- them buys back a row of the height the stop merge just saved. - local stopBtnRow = CreateFrame("Frame", nil, self.child) - stopBtnRow:SetSize(260, 24) - local addStopBtn = CreateFrame("Button", nil, stopBtnRow, "BackdropTemplate") - GUI:StyleButton(addStopBtn, { width = 127, height = 24, text = L["Add Color Stop"] }) - addStopBtn:SetPoint("LEFT", stopBtnRow, "LEFT", 0, 0) - addStopBtn:SetScript("OnClick", function() - -- Drop a new stop into the widest existing gap. - local a2 = cbtSorted(false) - local mx = scale.maxT or math.max(12, cbtT(a2[#a2]) + 2) - local bestT, bestGap = nil, -1 - for k = 1, #a2 do - local lo = cbtT(a2[k]) - local hi = (k < #a2) and cbtT(a2[k + 1]) or mx - local mid = math.floor((lo + hi) / 2) - if (hi - lo) > bestGap and mid > lo and mid < hi then - bestGap = hi - lo - bestT = mid - end - end - if bestT then bps[#bps + 1] = { threshold = bestT, color = { r = 1, g = 1, b = 1 } } end - ApplyColorByTime() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end) - - local resetStopsBtn = CreateFrame("Button", nil, stopBtnRow, "BackdropTemplate") - GUI:StyleButton(resetStopsBtn, { width = 127, height = 24, text = L["Reset to Default"] }) - resetStopsBtn:SetPoint("RIGHT", stopBtnRow, "RIGHT", 0, 0) - resetStopsBtn:SetScript("OnClick", function() - wipe(bps) - for _, dv in ipairs(DF.GlobalDefaults[bpKey]) do - bps[#bps + 1] = { threshold = dv.threshold, color = { r = dv.color.r, g = dv.color.g, b = dv.color.b } } - end - ApplyColorByTime() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end) - cbtGroup:AddWidget(stopBtnRow, 30) - - -- ── HOW THIS RENDERS ───────────────────────────────────────────────────── - -- One labelled preview per consumer, each rendering the EDITED ramp through - -- that consumer's lens, with the consumer's own dials on its row. This block - -- never changes shape — only the pixels inside the previews. - cbtGroup:AddWidget(GUI:CreateExpiringSubheader(self.child, L["How this renders"]), 26) - - -- Duration text: ALWAYS the ramp the text actually reads (its own unit, whatever - -- tab is up), gradient while Blend is on. Never dimmed — this row answers "what - -- does my countdown text look like right now", and its s/% toggle switches which - -- ramp that is, which the preview shows by changing colours, not by greying. - local textRow = CreateFrame("Frame", nil, self.child) - textRow:SetSize(260, 22) - local textLbl = textRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - textLbl:SetPoint("LEFT", 0, 0) - textLbl:SetText(L["Duration Text"]) - textLbl:SetTextColor(0.85, 0.85, 0.85) - local textUnitNow = (cbtGlobalDB.durationTextColorScale == "SECONDS") and "SECONDS" or "PERCENT" - local textStrip = BuildStrip(108, 14, true, textUnitNow) - textStrip:SetParent(textRow) - textStrip:SetPoint("LEFT", textRow, "LEFT", 88, 0) - local textUnit = GUI:CreateSegmentToggle(self.child, { - { value = "SECONDS", label = L["s"], tooltip = L["Seconds"] }, - { value = "PERCENT", label = L["%"], tooltip = L["Percent"] }, - }, cbtGlobalDB, "durationTextColorScale", function() - ApplyColorByTime() - if pageColors and pageColors.Refresh then pageColors:Refresh() end - end, { segmentWidth = 26, height = 18 }) - textUnit:SetParent(textRow) - textUnit:SetPoint("RIGHT", textRow, "RIGHT", 0, 0) - cbtGroup:AddWidget(textRow, 26) - - -- Blend belongs to the TEXT, so it sits inside the text block — above the note, - -- not between the note and Border & Tint, where it read as a border dial (the - -- exact opposite of what it is: borders can never blend). Each block here is - -- "row, its dials, then its note", so the note is what closes a block. - cbtGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Blend Colors Smoothly"], cbtGlobalDB, - "durationTextColorSmooth", function() - ApplyColorByTime() - RefreshPreview() -- rendering-only: retint the text preview in place - end), 30) - - -- Covers BOTH dials above: durationTextColorScale and durationTextColorSmooth - -- are account-wide, unlike the border's per-indicator unit — the pair of notes - -- exists to make that contrast readable. - cbtGroup:AddWidget(GUI:CreateNote(self.child, L["Shared by all duration text."], - { width = 260 })) - - -- Border & tint: always hard bands (|T escapes ignore the vertex colour a curve - -- writes), reading whichever ramp matches each indicator's own unit. - local borderRow = CreateFrame("Frame", nil, self.child) - borderRow:SetSize(260, 22) - local borderLbl = borderRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - borderLbl:SetPoint("LEFT", 0, 0) - borderLbl:SetText(L["Border & Tint"]) - borderLbl:SetTextColor(0.85, 0.85, 0.85) - local borderStrip = BuildStrip(108, 14, false, editUnit) - borderStrip:SetParent(borderRow) - borderStrip:SetPoint("LEFT", borderRow, "LEFT", 88, 0) - local borderTag = borderRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - borderTag:SetPoint("RIGHT", borderRow, "RIGHT", -2, 0) - borderTag:SetText(L["steps"]) - borderTag:SetTextColor(0.55, 0.55, 0.55) - cbtGroup:AddWidget(borderRow, 26) - cbtGroup:AddWidget(GUI:CreateNote(self.child, - L["Set per indicator. Can't blend colors — always steps."], - { width = 260 })) - - RefreshPreview() -- tint the legend strips built after the first pass - Add(cbtGroup, nil, 2) - if cbtSection then cbtSection:RegisterChild(cbtGroup) end - end -- BuildSection - - -- Column 2, not "both": the header belongs over the box it owns rather than - -- spanning the page (column 1 holds the dispel palette, which it does not own). - -- Width matches the box so the rule under the title lines up with it. - cbtSection = Add(GUI:CreateCollapsibleSection(self.child, L["Color by Time"], true, 280), 36, 2) - BuildSection() - end) - - -- ======================================== - -- CATEGORY: Bars - -- ======================================== - CreateCategory("bars", L["Bars"]) - - -- Bars > Health Bar - local pageHealthBar = CreateSubTab("bars", "bars_health", L["Health Bar"]) - BuildPage(pageHealthBar, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"healthColor", "healthOrientation", "healthTexture", "classColor", "smoothBars", "background", "missingHealth", "reducedMaxHealth"}, L["Health Bar"], "bars_health"), 25, 2) - - local currentSection = nil - local function AddToSection(widget, height, col) - Add(widget, height, col) - if currentSection then currentSection:RegisterChild(widget) end - return widget - end - - -- ===== HEALTH BAR SECTION ===== - local healthBarSection = Add(GUI:CreateCollapsibleSection(self.child, L["Health Bar"], true), 36, "both") - currentSection = healthBarSection - - -- ===== COLOR GROUP (Column 1) ===== - local colorGroup = GUI:CreateSettingsGroup(self.child, 280) - colorGroup:AddWidget(GUI:CreateHeader(self.child, L["Color"]), 40) - - local colorModes = { CLASS= L["Class Color"], CUSTOM= L["Custom Color"], PERCENT= L["Health Gradient"] } - colorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], colorModes, db, "healthColorMode", function() - self:RefreshStates() - DF:UpdateColorCurve() - -- Refresh health colors on all frames (same as alpha slider) - DF:RefreshAllVisibleFrames() - end), 55) - - local classAlpha = colorGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Bar Alpha"], 0, 1, 0.05, db, "classColorAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - classAlpha.hideOn = function(d) return d.healthColorMode ~= "CLASS" and d.healthColorMode ~= "PERCENT" end - - local customColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Custom Health Color"], db, "healthColor", true, nil, function() DF:LightweightUpdateHealthColor() end, true), 35) - customColor.hideOn = function(d) return d.healthColorMode ~= "CUSTOM" end - - AddToSection(colorGroup, nil, 1) - - -- ===== TEXTURE GROUP (Column 2) ===== - local textureGroup = GUI:CreateSettingsGroup(self.child, 280) - textureGroup:AddWidget(GUI:CreateHeader(self.child, L["Texture"]), 40) - textureGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healthTexture"), 55) - - local orientOptions = { - HORIZONTAL= L["Left to Right"], HORIZONTAL_INV= L["Right to Left"], - VERTICAL= L["Bottom to Top"], VERTICAL_INV= L["Top to Bottom"], - } - textureGroup:AddWidget(GUI:CreateDropdown(self.child, L["Fill Direction"], orientOptions, db, "healthOrientation", function() DF:UpdateAllFrames() end), 55) - textureGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Smooth Bar Animation"], db, "smoothBars", function() DF:UpdateAllFrames() end), 30) - - AddToSection(textureGroup, nil, 2) - - -- ===== GRADIENT PREVIEW (full width, conditional) ===== - local gradHeader = AddToSection(GUI:CreateHeader(self.child, L["Gradient"]), 40, "both") - gradHeader.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end - - local gradBar = AddToSection(GUI:CreateGradientBar(self.child, 550, 24, db), 35, "both") - gradBar.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end - - -- ===== HIGH HEALTH GROUP (Column 1, conditional) ===== - local highGroup = GUI:CreateSettingsGroup(self.child, 280) - highGroup:AddWidget(GUI:CreateHeader(self.child, L["High Health (100%)"]), 40) - highGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "healthColorHigh", false, function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 35) - highGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "healthColorHighUseClass", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) - highGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "healthColorHighWeight", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 55) - highGroup.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end - AddToSection(highGroup, nil, 1) - - -- ===== MEDIUM HEALTH GROUP (Column 2, conditional) ===== - local medGroup = GUI:CreateSettingsGroup(self.child, 280) - medGroup:AddWidget(GUI:CreateHeader(self.child, L["Medium Health (50%)"]), 40) - medGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "healthColorMedium", false, function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 35) - medGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "healthColorMediumUseClass", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) - medGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "healthColorMediumWeight", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 55) - medGroup.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end - AddToSection(medGroup, nil, 2) - - -- ===== LOW HEALTH GROUP (Column 1, conditional) ===== - local lowGroup = GUI:CreateSettingsGroup(self.child, 280) - lowGroup:AddWidget(GUI:CreateHeader(self.child, L["Low Health (0%)"]), 40) - lowGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "healthColorLow", false, function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 35) - lowGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "healthColorLowUseClass", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) - lowGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "healthColorLowWeight", function() if gradBar.UpdatePreview then gradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if gradBar.UpdatePreview then gradBar.UpdatePreview() end end, true), 55) - lowGroup.hideOn = function(d) return d.healthColorMode ~= "PERCENT" end - AddToSection(lowGroup, nil, 1) - - -- ===== BACKGROUND GROUP (Column 1) ===== - local bgGroup = GUI:CreateSettingsGroup(self.child, 280) - bgGroup:AddWidget(GUI:CreateHeader(self.child, L["Background"]), 40) - - local bgModes = { CUSTOM= L["Custom Color"], CLASS= L["Class Color"] } - bgGroup:AddWidget(GUI:CreateDropdown(self.child, L["Background Mode"], bgModes, db, "backgroundColorMode", function() - self:RefreshStates() - DF:LightweightUpdateBackgroundColor() - end), 55) - - local bgTextureOptions = DF:GetTextureList(true) - bgGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Background Texture"], db, "backgroundTexture", function() - DF:LightweightUpdateBackgroundColor() - end, bgTextureOptions), 55) - - local bgColor = bgGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "backgroundColor", true, nil, function() DF:LightweightUpdateBackgroundColor() end, true), 35) - bgColor.hideOn = function(d) return d.backgroundColorMode ~= "CUSTOM" end - - local bgClassAlpha = bgGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0, 1, 0.05, db, "backgroundClassAlpha", nil, function() DF:LightweightUpdateBackgroundColor() end, true), 55) - bgClassAlpha.hideOn = function(d) return d.backgroundColorMode ~= "CLASS" end - - AddToSection(bgGroup, nil, 1) - - currentSection = nil - AddSpace(GUI.Space.section, "both") - - -- ===== MISSING HEALTH SECTION ===== - local missingSection = Add(GUI:CreateCollapsibleSection(self.child, L["Missing Health"], true), 36, "both") - currentSection = missingSection - - -- ===== MISSING HEALTH GROUP (Column 1) ===== - local missingGroup = GUI:CreateSettingsGroup(self.child, 280) - missingGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - - local bgFillModes = { BACKGROUND= L["Background Only"], MISSING_HEALTH= L["Missing Health Only"], BOTH= L["Both"] } - local bgFillMode = missingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Background Fill"], bgFillModes, db, "backgroundMode", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 55) - bgFillMode.tooltip = L["Background Only: Normal solid background\nMissing Health Only: Shows colored bar where health is missing\nBoth: Shows both"] - - local missingHealthTextureOptions = DF:GetTextureList(false) - local missingHealthTexture = missingGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Missing Health Texture"], db, "missingHealthTexture", function() - DF:UpdateAllFrames() - end, missingHealthTextureOptions), 55) - missingHealthTexture.hideOn = function(d) return d.backgroundMode == "BACKGROUND" end - - local missingHealthColorModes = { CUSTOM= L["Custom Color"], CLASS= L["Class Color"], PERCENT= L["Health Gradient"] } - local missingHealthColorMode = missingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], missingHealthColorModes, db, "missingHealthColorMode", function() - self:RefreshStates() - DF:UpdateColorCurve() - DF:UpdateAllFrames() - end), 55) - missingHealthColorMode.hideOn = function(d) return d.backgroundMode == "BACKGROUND" end - - local missingHealthColor = missingGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Missing Health Color"], db, "missingHealthColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) - missingHealthColor.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "CUSTOM" end - - local missingHealthClassAlpha = missingGroup:AddWidget(GUI:CreateSlider(self.child, L["Class Color Alpha"], 0, 1, 0.05, db, "missingHealthClassAlpha", nil, function() DF:UpdateAllFrames() end, true), 55) - missingHealthClassAlpha.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "CLASS" end - - local missingHealthGradientAlpha = missingGroup:AddWidget(GUI:CreateSlider(self.child, L["Gradient Color Alpha"], 0, 1, 0.05, db, "missingHealthGradientAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - missingHealthGradientAlpha.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end - - AddToSection(missingGroup, nil, 1) - - -- ===== MISSING HEALTH GRADIENT PREVIEW (full width, conditional) ===== - local mhGradHeader = AddToSection(GUI:CreateHeader(self.child, L["Gradient"]), 40, "both") - mhGradHeader.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end - - local mhGradBar = AddToSection(GUI:CreateGradientBar(self.child, 550, 24, db, "missingHealthColor"), 35, "both") - mhGradBar.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end - - -- ===== MISSING HEALTH HIGH GROUP (Column 1, conditional) ===== - local mhHighGroup = GUI:CreateSettingsGroup(self.child, 280) - mhHighGroup:AddWidget(GUI:CreateHeader(self.child, L["High Health (100%)"]), 40) - mhHighGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "missingHealthColorHigh", false, function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 35) - mhHighGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "missingHealthColorHighUseClass", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) - mhHighGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "missingHealthColorHighWeight", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 55) - mhHighGroup.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end - AddToSection(mhHighGroup, nil, 1) - - -- ===== MISSING HEALTH MEDIUM GROUP (Column 2, conditional) ===== - local mhMedGroup = GUI:CreateSettingsGroup(self.child, 280) - mhMedGroup:AddWidget(GUI:CreateHeader(self.child, L["Medium Health (50%)"]), 40) - mhMedGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "missingHealthColorMedium", false, function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 35) - mhMedGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "missingHealthColorMediumUseClass", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) - mhMedGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "missingHealthColorMediumWeight", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 55) - mhMedGroup.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end - AddToSection(mhMedGroup, nil, 2) - - -- ===== MISSING HEALTH LOW GROUP (Column 1, conditional) ===== - local mhLowGroup = GUI:CreateSettingsGroup(self.child, 280) - mhLowGroup:AddWidget(GUI:CreateHeader(self.child, L["Low Health (0%)"]), 40) - mhLowGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "missingHealthColorLow", false, function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 35) - mhLowGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Class Color"], db, "missingHealthColorLowUseClass", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end), 30) - mhLowGroup:AddWidget(GUI:CreateSlider(self.child, L["Weight"], 1, 5, 1, db, "missingHealthColorLowWeight", function() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() end, function() DF:UpdateColorCurve() DF:RefreshAllVisibleFrames() if mhGradBar.UpdatePreview then mhGradBar.UpdatePreview() end end, true), 55) - mhLowGroup.hideOn = function(d) return d.backgroundMode == "BACKGROUND" or d.missingHealthColorMode ~= "PERCENT" end - AddToSection(mhLowGroup, nil, 1) - - currentSection = nil - - AddSpace(GUI.Space.section, "both") - - -- ===== REDUCED MAX HEALTH SECTION ===== - local reducedSection = Add(GUI:CreateCollapsibleSection(self.child, L["Reduced Max Health"], true), 36, "both") - currentSection = reducedSection - - -- ===== REDUCED MAX HEALTH SETTINGS GROUP (Column 1) ===== - local reducedGroup = GUI:CreateSettingsGroup(self.child, 280) - reducedGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - local reducedEnable = reducedGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable"], db, "reducedMaxHealthEnabled", function() self:RefreshStates() DF:UpdateAllFrames() end), 30) - reducedEnable.keepEnabled = true - reducedGroup.disableChildrenOn = function(d) return not d.reducedMaxHealthEnabled end - reducedGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Clip Health Bar"], db, "reducedMaxHealthClipHealthBar", function() DF:UpdateAllFrames() end), 30) - reducedGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "reducedMaxHealthTexture", function() DF:UpdateAllFrames() end), 55) - reducedGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "reducedMaxHealthColor", true, nil, function() DF:LightweightUpdateReducedMaxHealthColor() end, true), 35) - local reducedBlendOpts = { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"] } - reducedGroup:AddWidget(GUI:CreateDropdown(self.child, L["Blend Mode"], reducedBlendOpts, db, "reducedMaxHealthBlendMode", function() DF:UpdateAllFrames() end), 55) - AddToSection(reducedGroup, nil, 1) - - currentSection = nil - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "general_frame", label = L["Frame"]}, - -- LEGACY-TEXT-CLEANUP: legacy text page hidden; link removed - -- {pageId = "text_health", label = L["Health Text"]}, - {pageId = "bars_absorbs", label = L["Absorbs"]}, - }), 30, "both") - end) - - -- Bars > Resource Bar - local pageResource = CreateSubTab("bars", "bars_resource", L["Resource Bar"]) - BuildPage(pageResource, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"resourceBar"}, L["Resource Bar"], "bars_resource"), 25, 2) - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Resource Bar Settings"]), 40) - local resourceBarEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Resource Bar"], db, "resourceBarEnabled", function() - DF:UpdateAllPowerEventRegistration() - DF:UpdateAllFrames() - self:RefreshStates() - end), 30) - resourceBarEnable.keepEnabled = true - settingsGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Healers"], db, "resourceBarShowHealer", function() DF:UpdateAllFrames() end), 30) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Tanks"], db, "resourceBarShowTank", function() DF:UpdateAllFrames() end), 30) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["DPS"], db, "resourceBarShowDPS", function() DF:UpdateAllFrames() end), 30) - local showInSolo = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show in Solo Mode"], db, "resourceBarShowInSoloMode", function() DF:UpdateAllFrames() end), 30) - showInSolo.hideOn = function() return GUI.SelectedMode == "raid" end - Add(settingsGroup, nil, 1) - - -- ===== CLASS FILTER GROUP (Column 1) ===== - local classFilterGroup = GUI:CreateSettingsGroup(self.child, 280) - classFilterGroup:AddWidget(GUI:CreateHeader(self.child, L["Class Filter"]), 40) - classFilterGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - - local RB_CLASS_LIST = { - { token = "WARRIOR", name = L["Warrior"] }, - { token = "PALADIN", name = L["Paladin"] }, - { token = "HUNTER", name = L["Hunter"] }, - { token = "ROGUE", name = L["Rogue"] }, - { token = "PRIEST", name = L["Priest"] }, - { token = "DEATHKNIGHT", name = L["Death Knight"] }, - { token = "SHAMAN", name = L["Shaman"] }, - { token = "MAGE", name = L["Mage"] }, - { token = "WARLOCK", name = L["Warlock"] }, - { token = "MONK", name = L["Monk"] }, - { token = "DRUID", name = L["Druid"] }, - { token = "DEMONHUNTER", name = L["Demon Hunter"] }, - { token = "EVOKER", name = L["Evoker"] }, - } - - if not db.resourceBarClassFilter then - db.resourceBarClassFilter = {} - for _, info in ipairs(RB_CLASS_LIST) do - db.resourceBarClassFilter[info.token] = true - end - end - - for _, info in ipairs(RB_CLASS_LIST) do - classFilterGroup:AddWidget( - GUI:CreateCheckbox(self.child, info.name, db.resourceBarClassFilter, info.token, function() - DF:UpdateAllFrames() - end), 25 - ) - end - Add(classFilterGroup, nil, 1) - - -- ===== SIZE GROUP (Column 1) ===== - local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) - sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Size"]), 40) - sizeGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - local rbMatch = sizeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Match Health Bar Width/Height"], db, "resourceBarMatchWidth", function() DF:UpdateAllFrames() end), 30) - rbMatch.tooltip = L["Keeps the resource bar the same width as the health bar it sits under, so it stays lined up when you resize the frame. The Width slider below greys out while this is on."] - local widthSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Width / Length"], 10, 200, 1, db, "resourceBarWidth", nil, function() DF:LightweightUpdatePowerBarSize() end, true), 55) - widthSlider.disableOn = function(d) return d.resourceBarMatchWidth end - sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Height / Thickness"], 1, 20, 1, db, "resourceBarHeight", nil, function() DF:LightweightUpdatePowerBarSize() end, true), 55) - Add(sizeGroup, nil, 1) - - -- ===== POSITION GROUP (Column 1) ===== - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - positionGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - - local anchorOptions = { - TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], CENTER= L["Center"], - TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], - } - positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "resourceBarAnchor", function() DF:UpdateAllFrames() end), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "resourceBarX", nil, function() DF:LightweightUpdatePowerBarPosition() end, true), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "resourceBarY", nil, function() DF:LightweightUpdatePowerBarPosition() end, true), 55) - Add(positionGroup, nil, 1) - - -- ===== APPEARANCE GROUP (Column 2) — mirrors the Health Bar's Texture group: - -- Texture, Orientation / Reverse Fill, and Smooth Bar Animation in one place. ===== - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - appearanceGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - appearanceGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "resourceBarTexture", function() DF:UpdateAllFrames() end), 55) - - -- Keep Orientation (Horizontal/Vertical) and Reverse Fill as two explicit - -- controls — clearer than a combined "Fill Direction" dropdown, where an - -- option like "Bottom to Top" silently changes the orientation too. - local orientOptions = { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] } - appearanceGroup:AddWidget(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "resourceBarOrientation", function() DF:UpdateAllFrames() end), 55) - appearanceGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill Direction"], db, "resourceBarReverseFill", function() DF:UpdateAllFrames() end), 30) - - appearanceGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Smooth Bar Animation"], db, "resourceBarSmooth", function() DF:UpdateAllFrames() end), 30) - Add(appearanceGroup, nil, 2) - - -- ===== BACKGROUND GROUP (Column 2) ===== - local bgGroup = GUI:CreateSettingsGroup(self.child, 280) - bgGroup:AddWidget(GUI:CreateHeader(self.child, L["Background"]), 40) - bgGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - bgGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Background"], db, "resourceBarBackgroundEnabled", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 30) - local bgColor = bgGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "resourceBarBackgroundColor", true, nil, function() DF:LightweightUpdateResourceBarBackgroundColor() end, true), 35) - bgColor.disableOn = function(d) return not d.resourceBarBackgroundEnabled end - Add(bgGroup, nil, 2) - - -- ===== BORDER GROUP (Column 2) ===== - -- Stage 4.2: hand-rolled Show + Colour block expanded to the full - -- unified helper. include set tailored for a resource indicator: - -- alpha / inset / blendMode / gradient / shadow keep the visual - -- toolkit; classColor / roleColor match the bar's optional class - -- tinting (resourceBarClassColor) for cohesion. Skipped: animate - -- (resource bar is decoration, not an alert surface), offset (bar - -- has its own X/Y positioning controls above), colorByTime / - -- colorByType (no aura-state context). - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - borderGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - GUI:CreateBorderControls(borderGroup, db, "resourceBar", { - parent = self.child, - include = { alpha = true, inset = true, blendMode = true, - gradient = true, shadow = true, - classColor = true, roleColor = true }, - fullUpdate = function() DF:LightweightUpdateResourceBarBorder() end, - lightUpdate = function() DF:LightweightUpdateResourceBarBorder() end, - lightColors = function() DF:LightweightUpdateResourceBarBorderColor() end, - refreshStates = function() self:RefreshStates() end, - sizeMin = 1, sizeMax = 6, sizeStep = 1, - }) - Add(borderGroup, nil, 2) - - -- ===== FRAME LEVEL GROUP (Column 1) ===== - local frameLevelGroup = GUI:CreateSettingsGroup(self.child, 280) - frameLevelGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Level"]), 40) - frameLevelGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - frameLevelGroup:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "resourceBarFrameLevel", nil, function() DF:LightweightUpdateResourceBarFrameLevel() end, true)), 55) - Add(frameLevelGroup, nil, 1) - - -- ===== RESOURCE COLORS GROUP (Column 2) ===== - local colorGroup = GUI:CreateSettingsGroup(self.child, 280) - colorGroup:AddWidget(GUI:CreateHeader(self.child, L["Resource Colors"]), 40) - colorGroup.disableChildrenOn = function(d) return not d.resourceBarEnabled end - colorGroup:AddWidget(GUI:CreateLabel(self.child, L["Customize resource bar colors per power type. Shared across party and raid frames."], 260), 40) - -- Colour mode: Power Type (per-power colours below) / Class / Custom. - local RESOURCE_COLOR_MODES = { - POWER_TYPE = L["Power Type"], CLASS = L["Class"], CUSTOM = L["Custom"], - _order = { "POWER_TYPE", "CLASS", "CUSTOM" }, - } - local rbColorMode = colorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], RESOURCE_COLOR_MODES, db, "resourceBarColorMode", function() - DF:RefreshAllVisibleFrames() - self:RefreshStates() -- re-evaluate the custom colour picker's hideOn - end), 54) - rbColorMode.tooltip = L["Power Type gives each resource its own game colour — blue mana, yellow energy, red rage. Class colours every bar by the unit's class instead, and Custom uses one fixed colour for everyone."] - - -- Custom colour — only shown in Custom mode. - local resourceCustomColor = GUI:CreateColorPicker(self.child, L["Custom Color"], db, "resourceBarCustomColor", false, function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true) - resourceCustomColor.hideOn = function() return (db.resourceBarColorMode or "POWER_TYPE") ~= "CUSTOM" end - colorGroup:AddWidget(resourceCustomColor, 30) - - local powerColorsDB = DF.db.powerColors - if not powerColorsDB then - DF.db.powerColors = {} - powerColorsDB = DF.db.powerColors - end - - local POWER_LIST = { - { token = "MANA", name = L["Mana"] }, - { token = "RAGE", name = L["Rage"] }, - { token = "FOCUS", name = L["Focus"] }, - { token = "ENERGY", name = L["Energy"] }, - { token = "RUNIC_POWER", name = L["Runic Power"] }, - { token = "INSANITY", name = L["Insanity"] }, - { token = "FURY", name = L["Fury"] }, - { token = "PAIN", name = L["Pain"] }, - { token = "LUNAR_POWER", name = L["Lunar Power"] }, - { token = "MAELSTROM", name = L["Maelstrom"] }, - } - - for _, info in ipairs(POWER_LIST) do - local token = info.token - if not powerColorsDB[token] then - local default = PowerBarColor[token] - if default then - powerColorsDB[token] = { r = default.r, g = default.g, b = default.b, a = 1 } - end - end - colorGroup:AddWidget(GUI:CreateColorPicker(self.child, info.name, powerColorsDB, token, false, function() - DF:RefreshAllVisibleFrames() - end, function() - DF:RefreshAllVisibleFrames() - end, true), 30) - end - - -- Reset button - local resetPowerBtn = CreateFrame("Button", nil, self.child, "BackdropTemplate") - GUI:StyleButton(resetPowerBtn, { width = 260, height = 24, text = L["Reset All to Default"] }) - resetPowerBtn:SetScript("OnClick", function() - for _, info in ipairs(POWER_LIST) do - local default = PowerBarColor[info.token] - if default then - powerColorsDB[info.token] = { r = default.r, g = default.g, b = default.b, a = 1 } - end - end - DF:RefreshAllVisibleFrames() - if pageResource and pageResource.Refresh then - pageResource:Refresh() - end - end) - colorGroup:AddWidget(resetPowerBtn, 30) - - Add(colorGroup, nil, 2) - end) - - -- Bars > Absorbs (combined Absorb Shield + Heal Absorb with collapsible sections) - local pageAbsorb = CreateSubTab("bars", "bars_absorb", L["Absorbs"]) - BuildPage(pageAbsorb, function(self, db, Add, AddSpace) - -- Copy button at top - Add(CreateCopyButton(self.child, {"absorbBar", "healAbsorb"}, L["Absorbs"], "bars_absorb"), 25, 2) - - local currentSection = nil - - -- Helper to add widgets to current section - local function AddToSection(widget, height, col) - Add(widget, height, col) - if currentSection then - currentSection:RegisterChild(widget) - end - return widget - end - - -- ===== ABSORB SHIELD SECTION ===== - local absorbSection = Add(GUI:CreateCollapsibleSection(self.child, L["Absorb Shield"], true), 36, "both") - currentSection = absorbSection - - local modeOptions = { - OVERLAY = L["Overlay (on health bar)"], - ATTACHED = L["Attached to Health"], - ATTACHED_OVERFLOW = L["Attached + Overflow"], - FLOATING = L["Floating Bar"], - } - AddToSection(GUI:CreateDropdown(self.child, L["Display Mode"], modeOptions, db, "absorbBarMode", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 55, 1) - - local textureOptions = DF:GetTextureList() - -- Add stripe textures if not already present - local stripeTextures = { - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft"]= "DF Stripes Soft", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft_Wide"]= "DF Stripes Soft Wide", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes"]= "DF Stripes", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Sparse"]= "DF Stripes Sparse", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Medium"]= "DF Stripes Medium", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense"]= "DF Stripes Dense", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Very_Dense"]= "DF Stripes Very Dense", - } - for path, name in pairs(stripeTextures) do - if not textureOptions[path] then - textureOptions[path] = name - end - end - AddToSection(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "absorbBarTexture", function() DF:UpdateAllFrames() end, textureOptions), 55, 1) - - AddToSection(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "absorbBarColor", true, nil, function() DF:LightweightUpdateAbsorbBarColor() end, true), 35, 1) - - local blendOptions = { BLEND= L["Normal (BLEND)"], ADD= L["Additive (ADD)"] } - AddToSection(GUI:CreateDropdown(self.child, L["Blend Mode"], blendOptions, db, "absorbBarBlendMode", function() DF:UpdateAllFrames() end), 55, 1) - - local overlayRev = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Overlay Fill"], db, "absorbBarOverlayReverse", function() DF:UpdateAllFrames() end), 25, 1) - overlayRev.hideOn = function(d) return d.absorbBarMode ~= "OVERLAY" and d.absorbBarMode ~= "ATTACHED_OVERFLOW" end - - local absorbClampOptions = { - [0] = L["None (no clamping)"], - [1] = L["Missing Health"], - [2] = L["Max Health"], - } - local absorbClampDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Clamp Mode"], absorbClampOptions, db, "absorbBarAttachedClampMode", function() DF:UpdateAllFrames() end), 55, 1) - absorbClampDropdown.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" and d.absorbBarMode ~= "ATTACHED_OVERFLOW" end - - local absorbShowOvershield = AddToSection(GUI:CreateCheckbox(self.child, L["Show Overshield Glow"], db, "absorbBarShowOvershield", function() DF:UpdateAllFrames() end), 25, 1) - absorbShowOvershield.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end - absorbShowOvershield.tooltip = L["Shows a glow at max health when absorb exceeds the clamp limit."] - - local absorbOvershieldStyleOptions = { - SPARK = L["Spark"], - LINE = L["Line"], - GRADIENT = L["Gradient"], - GLOW = L["Glow"], - } - -- Overshield glow detail controls: HIDE for the wrong bar mode (variant), but - -- GREY (disabled-in-place) when the boolean "Show Overshield Glow" toggle is off. - local absorbOvershieldStyle = AddToSection(GUI:CreateDropdown(self.child, L["Glow Style"], absorbOvershieldStyleOptions, db, "absorbBarOvershieldStyle", function() DF:UpdateAllFrames() end), 55, 1) - absorbOvershieldStyle.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end - absorbOvershieldStyle.disableOn = function(d) return not d.absorbBarShowOvershield end - - local absorbOvershieldColor = AddToSection(GUI:CreateColorPicker(self.child, L["Glow Color"], db, "absorbBarOvershieldColor", false, nil, function() DF:UpdateAllFrames() end), 35, 1) - absorbOvershieldColor.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end - absorbOvershieldColor.disableOn = function(d) return not d.absorbBarShowOvershield end - - local absorbOvershieldAlpha = AddToSection(GUI:CreateSlider(self.child, L["Glow Alpha"], 0.1, 1, 0.05, db, "absorbBarOvershieldAlpha", nil, function() DF:UpdateAllFrames() end, true), 55, 1) - absorbOvershieldAlpha.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end - absorbOvershieldAlpha.disableOn = function(d) return not d.absorbBarShowOvershield end - - local absorbOvershieldReverse = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Position"], db, "absorbBarOvershieldReverse", function() DF:UpdateAllFrames() end), 25, 1) - absorbOvershieldReverse.hideOn = function(d) return d.absorbBarMode ~= "ATTACHED" end - absorbOvershieldReverse.disableOn = function(d) return not d.absorbBarShowOvershield end - absorbOvershieldReverse.tooltip = L["Moves the glow to the opposite side (no HP side instead of max HP side)."] - - -- Floating mode settings (column 2) - local floatingHeader = AddToSection(GUI:CreateHeader(self.child, L["Floating Bar Position"]), 45, 2) - floatingHeader.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local orientOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } - local orientDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "absorbBarOrientation", function() DF:UpdateAllFrames() end), 55, 1) - orientDropdown.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local revFill = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "absorbBarReverse", function() DF:UpdateAllFrames() end), 25, 2) - revFill.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local widthSlider = AddToSection(GUI:CreateSlider(self.child, L["Width"], 10, 200, 1, db, "absorbBarWidth", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) - widthSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local heightSlider = AddToSection(GUI:CreateSlider(self.child, L["Height"], 1, 30, 1, db, "absorbBarHeight", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) - heightSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local anchorOptions = { - TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], CENTER= L["Center"], - TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], - } - local anchorDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "absorbBarAnchor", function() DF:UpdateAllFrames() end), 55, 1) - anchorDropdown.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local xSlider = AddToSection(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "absorbBarX", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) - xSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local ySlider = AddToSection(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "absorbBarY", nil, function() DF:LightweightUpdateAbsorbBar() end, true), 55, 1) - ySlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local bgColorPicker = AddToSection(GUI:CreateColorPicker(self.child, L["Background Color"], db, "absorbBarBackgroundColor", true, nil, function() DF:LightweightUpdateAbsorbBarColor() end, true), 35, 2) - bgColorPicker.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - local levelSlider = AddToSection(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "absorbBarFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("absorb") end, true)), 55, 1) - levelSlider.hideOn = function(d) return d.absorbBarMode ~= "FLOATING" end - - currentSection = nil - AddSpace(GUI.Space.section, "both") - - -- ===== HEAL ABSORB SECTION ===== - local healAbsorbSection = Add(GUI:CreateCollapsibleSection(self.child, L["Heal Absorb"], true), 36, "both") - currentSection = healAbsorbSection - - AddToSection(GUI:CreateLabel(self.child, L["Shows effects that reduce incoming healing (like Necrotic stacks)."], 260), 25, 1) - - local healModeOptions = { - OVERLAY = L["Overlay (on health bar)"], - ATTACHED = L["Attached to Health"], - FLOATING = L["Floating Bar"], - } - AddToSection(GUI:CreateDropdown(self.child, L["Display Mode"], healModeOptions, db, "healAbsorbBarMode", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 55, 1) - - local healTextureOptions = DF:GetTextureList() - -- Add stripe textures if not already present - local healStripeTextures = { - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft"]= "DF Stripes Soft", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Soft_Wide"]= "DF Stripes Soft Wide", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes"]= "DF Stripes", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Sparse"]= "DF Stripes Sparse", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Medium"]= "DF Stripes Medium", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense"]= "DF Stripes Dense", - ["Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Very_Dense"]= "DF Stripes Very Dense", - } - for path, name in pairs(healStripeTextures) do - if not healTextureOptions[path] then - healTextureOptions[path] = name - end - end - AddToSection(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healAbsorbBarTexture", function() DF:UpdateAllFrames() end, healTextureOptions), 55, 1) - - AddToSection(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "healAbsorbBarColor", true, nil, function() DF:LightweightUpdateHealAbsorbBarColor() end, true), 35, 1) - - local healBlendOptions = { BLEND= L["Normal (BLEND)"], ADD= L["Additive (ADD)"] } - AddToSection(GUI:CreateDropdown(self.child, L["Blend Mode"], healBlendOptions, db, "healAbsorbBarBlendMode", function() DF:UpdateAllFrames() end), 55, 1) - - local healOverlayRev = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Overlay Fill"], db, "healAbsorbBarOverlayReverse", function() DF:UpdateAllFrames() end), 25, 1) - healOverlayRev.hideOn = function(d) return d.healAbsorbBarMode ~= "OVERLAY" end - - -- Heal Absorb Floating mode settings (column 2) - local healFloatingHeader = AddToSection(GUI:CreateHeader(self.child, L["Floating Bar Position"]), 45, 2) - healFloatingHeader.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healOrientDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "healAbsorbBarOrientation", function() DF:UpdateAllFrames() end), 55, 1) - healOrientDropdown.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healRevFill = AddToSection(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "healAbsorbBarReverse", function() DF:UpdateAllFrames() end), 25, 2) - healRevFill.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healWidthSlider = AddToSection(GUI:CreateSlider(self.child, L["Width"], 10, 200, 1, db, "healAbsorbBarWidth", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) - healWidthSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healHeightSlider = AddToSection(GUI:CreateSlider(self.child, L["Height"], 1, 30, 1, db, "healAbsorbBarHeight", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) - healHeightSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healAnchorDropdown = AddToSection(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "healAbsorbBarAnchor", function() DF:UpdateAllFrames() end), 55, 1) - healAnchorDropdown.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healXSlider = AddToSection(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "healAbsorbBarX", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) - healXSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healYSlider = AddToSection(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "healAbsorbBarY", nil, function() DF:LightweightUpdateHealAbsorbBar() end, true), 55, 1) - healYSlider.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - local healBgColorPicker = AddToSection(GUI:CreateColorPicker(self.child, L["Background Color"], db, "healAbsorbBarBackgroundColor", true, nil, function() DF:LightweightUpdateHealAbsorbBarColor() end, true), 35, 2) - healBgColorPicker.hideOn = function(d) return d.healAbsorbBarMode ~= "FLOATING" end - - currentSection = nil - end) - - -- Bars > Heal Prediction - local pageHealPrediction = CreateSubTab("bars", "bars_healpred", L["Heal Prediction"]) - BuildPage(pageHealPrediction, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"healPrediction"}, L["Heal Prediction"], "bars_healpred"), 25, 2) - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Heal Prediction"]), 40) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Heal Prediction"], db, "healPredictionEnabled", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 30) - - local overhealCheckbox = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Overheal"], db, "healPredictionShowOverheal", function() DF:UpdateAllFrames() end), 30) - overhealCheckbox.disableOn = function(d) return not d.healPredictionEnabled end - overhealCheckbox.tooltip = L["When enabled, shows incoming heals even if they would overheal."] - - local modeOptions = { OVERLAY= L["Attached to Health"], FLOATING= L["Floating Bar"] } - local modeDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Display Mode"], modeOptions, db, "healPredictionMode", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 55) - modeDropdown.disableOn = function(d) return not d.healPredictionEnabled end - - local showModeOptions = { - ALL = L["All Incoming"], MINE = L["My Heals"], OTHERS = L["Others' Heals"], - SPLIT = L["Split (Mine + Others)"], - _order = { "ALL", "MINE", "OTHERS", "SPLIT" }, - } - local showModeDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Show Heals From"], showModeOptions, db, "healPredictionShowMode", function() - -- Rebuild so the colour picker(s) rebind to the selected mode. - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - DF:UpdateAllFrames() - end), 55) - showModeDropdown.disableOn = function(d) return not d.healPredictionEnabled end - showModeDropdown.tooltip = L["Which incoming heals the bar shows: all sources, only yours, or only from others."] - - local textureOptions = DF:GetTextureList() - local texDropdown = settingsGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healPredictionTexture", function() DF:UpdateAllFrames() end, textureOptions), 55) - texDropdown.disableOn = function(d) return not d.healPredictionEnabled end - - -- Colour picker(s): Split shows both segment colours; other modes show - -- the single colour for the selected mode. The mode dropdown rebuilds - -- the page so these rebind when the mode changes. - if db.healPredictionShowMode == "SPLIT" then - local myColor = settingsGroup:AddWidget(GUI:CreateColorPicker(self.child, L["My Heals Color"], db, "healPredictionMyColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) - myColor.disableOn = function(d) return not d.healPredictionEnabled end - local othersColor = settingsGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Others' Heals Color"], db, "healPredictionOthersColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) - othersColor.disableOn = function(d) return not d.healPredictionEnabled end - else - local showModeColorKey = (db.healPredictionShowMode == "ALL" and "healPredictionAllColor") - or (db.healPredictionShowMode == "OTHERS" and "healPredictionOthersColor") - or "healPredictionMyColor" - local myColor = settingsGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Heal Prediction Color"], db, showModeColorKey, true, nil, function() DF:UpdateAllFrames() end, true), 35) - myColor.disableOn = function(d) return not d.healPredictionEnabled end - end - - local blendOptions = { BLEND= L["Normal (BLEND)"], ADD= L["Additive (ADD)"] } - local blendDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Blend Mode"], blendOptions, db, "healPredictionBlendMode", function() DF:UpdateAllFrames() end), 55) - blendDropdown.disableOn = function(d) return not d.healPredictionEnabled end - - Add(settingsGroup, nil, 1) - - - -- ===== FLOATING POSITION GROUP (Column 1, conditional) ===== - local floatingGroup = GUI:CreateSettingsGroup(self.child, 280) - floatingGroup:AddWidget(GUI:CreateHeader(self.child, L["Floating Bar Position"]), 40) - - local orientOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } - local orientDropdown = floatingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Orientation"], orientOptions, db, "healPredictionOrientation", function() DF:UpdateAllFrames() end), 55) - orientDropdown.disableOn = function(d) return not d.healPredictionEnabled end - - local revFill = floatingGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "healPredictionReverse", function() DF:UpdateAllFrames() end), 30) - revFill.disableOn = function(d) return not d.healPredictionEnabled end - - local widthSlider = floatingGroup:AddWidget(GUI:CreateSlider(self.child, L["Width"], 10, 200, 1, db, "healPredictionWidth", nil, function() DF:UpdateAllFrames() end, true), 55) - widthSlider.disableOn = function(d) return not d.healPredictionEnabled end - - local heightSlider = floatingGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 30, 1, db, "healPredictionHeight", nil, function() DF:UpdateAllFrames() end, true), 55) - heightSlider.disableOn = function(d) return not d.healPredictionEnabled end - - floatingGroup.hideOn = function(d) return d.healPredictionMode ~= "FLOATING" end - Add(floatingGroup, nil, 1) - - -- ===== FLOATING ANCHOR GROUP (Column 2, conditional) ===== - local anchorGroup = GUI:CreateSettingsGroup(self.child, 280) - anchorGroup:AddWidget(GUI:CreateHeader(self.child, L["Floating Bar Anchor"]), 40) - - local anchorOptions = { - CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], - TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], - } - local anchorDropdown = anchorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "healPredictionAnchor", function() DF:UpdateAllFrames() end), 55) - anchorDropdown.disableOn = function(d) return not d.healPredictionEnabled end - - local xSlider = anchorGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "healPredictionX", nil, function() DF:UpdateAllFrames() end, true), 55) - xSlider.disableOn = function(d) return not d.healPredictionEnabled end - - local ySlider = anchorGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "healPredictionY", nil, function() DF:UpdateAllFrames() end, true), 55) - ySlider.disableOn = function(d) return not d.healPredictionEnabled end - - local bgColorPicker = anchorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "healPredictionBackgroundColor", true, nil, function() DF:UpdateAllFrames() end, true), 35) - bgColorPicker.disableOn = function(d) return not d.healPredictionEnabled end - - anchorGroup.hideOn = function(d) return d.healPredictionMode ~= "FLOATING" end - Add(anchorGroup, nil, 2) - end) - - -- ======================================== - -- CATEGORY: Text - -- ======================================== - CreateCategory("text", L["Text"]) - - -- LEGACY-TEXT-CLEANUP (v4.4.x): Name/Health/Status built-in text settings are - -- replaced by the Text Designer. These three pages are hidden via `if false` - -- (not deleted, so they can be restored). Remove this block, the legacy text - -- render path (see DF:IsLegacyTextHidden in Frames/Core.lua), and the legacy - -- *Text* defaults in Config.lua in a future release once the Text Designer - -- fully supersedes them. - - -- Text > Text Designer - -- See spec at docs/superpowers/specs/2026-05-22-text-designer-phase1-design.md - local pageTextDesigner = CreateSubTab("text", "text_designer", L["Text Designer"]) - BuildPage(pageTextDesigner, function(self, db, Add, AddSpace, AddSyncPoint) - if DF.BuildTextDesignerPage then - DF.BuildTextDesignerPage(GUI, self, db) - end - end) - - -- ======================================== - -- CATEGORY: Auras - -- ======================================== - CreateCategory("auras", L["Auras"]) - - -- Auras > Aura Filters (the merged page: pick filters AND edit their spells) - -- - -- Keeps the FAMILIAR NAME while the page id stays "auras_filterdesigner". That is - -- deliberate: every cross-link, Search entry and _fdSelect* entry point already - -- targets that id, so relabelling costs nothing whereas renaming the id would mean - -- chasing all of them. The old "auras_filters" page is gone -- its filter switches - -- moved into this page's left-hand list, its ordering and duration controls onto - -- the Buffs and Debuffs pages. - -- - -- SECTION KEYS, spelled out rather than stemmed. Ownership is longest-prefix-wins - -- (DF:SectionOwnsKey), so: - -- * "buffFilterSelection" beats the Buffs page's broad "buff" - -- * "debuffFilter" / "debuffBlacklist" beat the Debuffs page's broad "debuff" - -- * the three scope keys are listed INDIVIDUALLY because the sort keys that just - -- moved away (directBuffSortOrder, directDebuffSort*) share the "directBuff" / - -- "directDebuff" stems. A stem here would drag them back, and this page's - -- Copy/Reset would silently reach into the bar pages. - local pageFilterDesigner = CreateSubTab("auras", "auras_filterdesigner", L["Aura Filters"]) - BuildPage(pageFilterDesigner, function(self, db, Add, AddSpace, AddSyncPoint) - Add(CreateCopyButton(self.child, { - "buffFilterSelection", "debuffFilter", "debuffBlacklist", - "directBuffShowAll", "directBuffOnlyMine", "directDebuffShowAll", - }, L["Aura Filters"], "auras_filterdesigner"), 25, 2) - if DF.BuildFilterDesignerPage then DF.BuildFilterDesignerPage(GUI, self, db) end - - -- See Also, after the page's own content. This page positions its panels - -- absolutely and reports its height through an Add()ed spacer, so anything - -- Add()ed afterwards lands below them -- which is where a footer belongs. - -- - -- It also does real work here rather than being decoration: the four links - -- ARE the answer to "what uses these filters". Naming the consumers as - -- somewhere you can go beats another sentence explaining that they exist, - -- and this was the only page under Auras without a See Also bar. - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_buffs", label = L["Buffs"]}, - {pageId = "auras_debuffs", label = L["Debuffs"]}, - {pageId = "auras_defensiveicon", label = L["Defensive Icon"]}, - {pageId = "auras_auradesigner", label = L["Aura Designer"]}, - }), 30, "both") - end) - - -- Auras > Aura Designer - local pageAuraDesigner = CreateSubTab("auras", "auras_auradesigner", L["Aura Designer"]) - BuildPage(pageAuraDesigner, function(self, db, Add, AddSpace, AddSyncPoint) - if DF.BuildAuraDesignerPage then - DF.BuildAuraDesignerPage(GUI, self, db) - end - end) - - -- Auras > Aura Blacklist: RETIRED as a standalone page. The debuff blacklist - -- now lives inside the Filter Designer (Debuffs > Blacklist) — one home for - -- all per-spell aura control. Backend unchanged (AuraBlacklist/Config.lua + - -- Features/Auras.lua applyDebuffBlacklist); stored data carries over. - - -- Auras > Buffs (combined Layout + Appearance with collapsible sections) - local pageBuffs = CreateSubTab("auras", "auras_buffs", L["Buffs"]) - BuildPage(pageBuffs, function(self, db, Add, AddSpace, AddSyncPoint) - -- ======================================== - -- AD COEXISTENCE INFO BANNER - -- Shows when Aura Designer is active (with or without buffs). - -- ======================================== - local adBanner = GUI:CreateInfoBanner(self.child, {tone = "info"}) - - -- Link markup helper: |cCOLOR|HlinkData|hText|h|r — the banner recolours - -- links via the theme, so the markup colour is only a placeholder. - local function adLink(data, text) - return "|cffffffff|H" .. data .. "|h" .. text .. "|h|r" - end - local function adOnLink(data) - if data == "enableBuffs" then - db.showBuffs = true - self:RefreshStates() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - elseif data == "openAD" then - if GUI.SelectTab then GUI.SelectTab("auras_auradesigner") end - end - end - - -- Refresh banner content based on current state - adBanner.refreshContent = function(b, d) - local _adCfg = DF.GetModeAuraDesigner and DF:GetModeAuraDesigner((d == DF.db.raid) and "raid" or "party") - local adEnabled = _adCfg and _adCfg.enabled - if adEnabled and d.showBuffs then - b:SetHTML(L["Aura Designer is active alongside Buffs."] .. " " .. - adLink("openAD", L["Open Aura Designer"]), adOnLink) - elseif adEnabled and not d.showBuffs then - b:SetHTML(L["Buffs are disabled. Aura Designer is managing your auras."] .. " " .. - adLink("enableBuffs", L["Enable Buffs"]) .. " " .. - adLink("openAD", L["Open Aura Designer"]), adOnLink) - end - end - - adBanner.hideOn = function(d) - local _adCfg = DF.GetModeAuraDesigner and DF:GetModeAuraDesigner((d == DF.db.raid) and "raid" or "party") - return not (_adCfg and _adCfg.enabled) - end - - Add(adBanner, 32, "both") - - -- ======================================== - -- AD DISCOVERY BANNER - -- The INVERSE of the coexistence banner above: shown only when the Aura - -- Designer is NOT active, to point users who want more than one look for - -- every buff at per-slot control + advanced indicators. Its hideOn is the - -- exact negation of adBanner's, so precisely one AD banner ever occupies - -- this slot (a hidden banner collapses to zero height — no gap). - -- success tone (an inviting green), but a "widget" glyph overrides the tone's - -- default check so it reads as "advanced indicators available", not a - -- completed-state confirmation. Reuses adLink/adOnLink (the openAD path). - -- ======================================== - local adPromoBanner = GUI:CreateInfoBanner(self.child, {tone = "success"}) - adPromoBanner:SetIconTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\widget_small") - adPromoBanner.refreshContent = function(b) - b:SetHTML(L["Want per-spell control? The Aura Designer lets you place any buff exactly where you want, with advanced indicators — expiry glyphs, duration bars, custom borders and sounds."] .. " " .. - adLink("openAD", L["Open Aura Designer"]), adOnLink) - end - adPromoBanner.hideOn = function(d) - local _adCfg = DF.GetModeAuraDesigner and DF:GetModeAuraDesigner((d == DF.db.raid) and "raid" or "party") - return (_adCfg and _adCfg.enabled) and true or false -- hide when AD IS active - end - Add(adPromoBanner, 32, "both") - - -- Copy button at top right - -- "directBuff" covers the Order & Limits sort keys (directBuffSortOrder / - -- SortMineFirst / SortReverse) — they do not start with "buff", so they were - -- owned by no section and skipped by Copy, Sync and Reset alike. - Add(CreateCopyButton(self.child, {"buff", "showBuffs", "directBuff"}, L["Buffs"], "auras_buffs"), 25, 2) - - -- ===== DEDUPLICATION ===== - local dedupGroup = GUI:CreateSettingsGroup(self.child, 280) - dedupGroup:AddWidget(GUI:CreateHeader(self.child, L["Deduplication"]), 40) - -- The 12.1 alert banner that used to sit here is gone: both halves of the - -- toggle are expressible again (Aura Designer via excludeSpellIDs, the - -- Defensive Bar via its own resolved spell-ID map or a negated category — - -- see BuildDirectBuffFilters / BuildAuraRowConfig), and the multi-filter - -- duplicate it warned about cannot happen on a single-group buff row. - -- What the checkbox does now fits a tooltip; a danger banner would read as - -- "something is broken here". - local dedupCb = GUI:CreateCheckbox(self.child, L["Hide Duplicate Buffs"], db, "buffDeduplicateDefensives", function() - -- Bump the aura layout version so the factory buff row rebuilds with the new - -- exclusion set (InvalidateAuraLayout -> RefreshFactoryRows -> DriveBuffFactory); - -- UpdateAllAuras re-scans for the legacy (pre-12.1) dedup path. - DF:InvalidateAuraLayout() - DF:UpdateAllAuras() - end) - dedupCb.tooltip = L["Hides buffs that are already shown elsewhere — by an Aura Designer indicator, or on the Defensive Bar — so they don't appear twice."] - dedupGroup:AddWidget(dedupCb, 30) - Add(dedupGroup, nil, 1) - - local anchorOptions = { - CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], - TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], - } - - -- Settings Group (col1) - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - local showBuffsCb = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Buffs"], db, "showBuffs", function() - self:RefreshStates() - -- Re-scan auras on visible frames (not just layout): the show/hide gate - -- lives in the UNIT_AURA-driven UpdateAuras path, so UpdateAllFrames alone - -- (layout-only) leaves already-shown auras until the next aura event. Use - -- the same refresh the Max Buffs slider uses. - DF:RefreshAllVisibleFrames() - end), 30) - -- Re-sync checked state when value changes externally (e.g. AD banner click) - showBuffsCb.refreshContent = function(self) - local onShow = self:GetScript("OnShow") - if onShow then onShow(self) end - end - local buffMax = settingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Buffs"], 0, 8, 1, db, "buffMax", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - buffMax.disableOn = function(d) return not d.showBuffs end - Add(settingsGroup, nil, 1) - - -- Appearance Group (col2). Icon Size / Scale / Alpha are how the row LOOKS, so - -- they sit in column 2 with the other styling, matching Missing Buffs and - -- Defensive Icon. They used to live in Settings above, which made this the only - -- aura family where the same three sliders were classed as geometry. - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - local buffSize = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 10, 40, 1, db, "buffSize", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffSize.disableOn = function(d) return not d.showBuffs end - local buffScale = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "buffScale", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffScale.disableOn = function(d) return not d.showBuffs end - local buffAlpha = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.0, 1.0, 0.05, db, "buffAlpha", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffAlpha.disableOn = function(d) return not d.showBuffs end - Add(appearanceGroup, nil, 2) - - -- Layout Group (col1) - local gridGroup = GUI:CreateSettingsGroup(self.child, 280) - gridGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) - local buffWrap = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Per Row"], 1, 8, 1, db, "buffWrap", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - -- Greys out (NOT a 12.1 frost) whenever the row can't have more than one icon per - -- line: the row is off, or the growth is vertical-primary, where the native flow - -- renders a single column and "icons per row" has nothing to count. That's ordinary - -- contextual state — the control works fine horizontally — so it uses the normal grey - -- seam rather than the 12.1 blocked registry, which is reserved for "the game cannot - -- do this". Flipping Orientation re-enables it live via RefreshStates. - -- (Why a vertical column is unavoidable, re-verified against the 68914 dump: - -- ValidateAuraGroupLayoutOptions accepts only elementSpacing / lineSpacing / - -- groupSpacing / groupLineSpacing / forceNewLine / elementWidth / elementHeight / - -- layoutIndex — no primary-axis field and no wrap count — and - -- SetFlowLayoutGrowthDirection(h, v) picks which way lines grow, not whether the - -- flow is column-primary.) - buffWrap.disableOn = function(d) - if not d.showBuffs then return true end - local g = d.buffGrowth or "" - -- Vertical-primary AND vertical-centred growth both render a single column. - return DF:FactoryOwnsBuffRow(d) and (g:sub(1, 2) == "UP" or g:sub(1, 4) == "DOWN" - or g == "CENTER_LEFT" or g == "CENTER_RIGHT") - end - -- CENTER growth direction: supported on factory rows since the centre-pinned - -- box in AuraContainer.lua resolveGrowthLayout (the self-sizing container - -- keeps the row centred) — the old blocked-registry entry is gone. - local buffPaddingX = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing X"], -5, 10, 1, db, "buffPaddingX", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffPaddingX.disableOn = function(d) return not d.showBuffs end - local buffPaddingY = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing Y"], -5, 10, 1, db, "buffPaddingY", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffPaddingY.disableOn = function(d) return not d.showBuffs end - Add(gridGroup, nil, 1) - - -- Position Group (col1) - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - local buffAnchor = positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "buffAnchor", nil), 55) - buffAnchor.disableOn = function(d) return not d.showBuffs end - local buffGrowth = positionGroup:AddWidget(GUI:CreateGrowthControl(self.child, db, "buffGrowth", nil), 155) - buffGrowth.disableOn = function(d) return not d.showBuffs end - local buffOffsetX = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "buffOffsetX", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffOffsetX.disableOn = function(d) return not d.showBuffs end - local buffOffsetY = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "buffOffsetY", nil, function() DF:LightweightUpdateAuraPosition("buff") end, true), 55) - buffOffsetY.disableOn = function(d) return not d.showBuffs end - Add(positionGroup, nil, 1) - - -- Border Group (col2) - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - -- Full border toolkit via the unified helper (Stage 5.5 Phase 2). No - -- class/role colour (aura indicators aren't unit-class). Greys out when - -- buffs are off, like every other control on this page. - -- Border Animation is intentionally NOT offered on the buff/debuff rows: - -- these containers can hold many icons and animating each border is a - -- per-frame FPS cost, so DF exposes border animations only on the - -- low-count elements (Defensive / Missing Buff) and the Aura Designer. - GUI:CreateBorderControls(borderGroup, db, "buff", { - parent = self.child, - include = { inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true }, - sizeMin = 0, sizeMax = 8, sizeStep = 1, - fullUpdate = function() if DF.UpdateAllFrames then DF:UpdateAllFrames() end end, - lightUpdate = function() DF:LightweightUpdateAuraBorder("buff") end, - lightColors = function() DF:LightweightUpdateAuraBorder("buff") end, - refreshStates = function() self:RefreshStates() end, - disableWhen = function(d) return not d.showBuffs end, - }) - Add(borderGroup, nil, 2) - - -- Stack Count Group (col2) — the shared TextStyle control block (font/scale/ - -- outline/shadow/colour/anchor/offsets/justify) + the feature-specific extras. - local stackCountGroup = GUI:CreateSettingsGroup(self.child, 280) - stackCountGroup:AddWidget(GUI:CreateHeader(self.child, L["Stack Count"]), 40) - GUI:CreateTextControls(stackCountGroup, db, "buffStack", { - parent = self.child, - include = { color = true }, - onChange = function() DF:LightweightUpdateAuraStackText("buff") end, - onDrag = function() DF:LightweightUpdateAuraStackText("buff") end, - }) - -- (No "Min Stacks to Show": a stacks formatter is FORBIDDEN on container rows — it - -- throws on the secret combat stack count inside Blizzard's dirty pass and bricks - -- the container (see the Features/Auras.lua tombstone). Native display is - -- "counts > 1", so a custom minimum cannot be expressed; the setting is gone.) - -- Grey the whole group when Buffs are off, matching Settings/Position/Grid. - stackCountGroup.disableChildrenOn = function(d) return not d.showBuffs end - Add(stackCountGroup, nil, 1) - - -- Duration Text Group (col2) - local durationGroup = GUI:CreateSettingsGroup(self.child, 280) - durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration"]), 40) - durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "buffShowDuration", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 30) - -- The cooldown swipe (radial sweep) is the OTHER way time-remaining is - -- shown, so it lives here with Duration Text rather than under Border. - durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Cooldown Swipe"], db, "buffHideSwipe", nil), 30) - -- Icon-sized formats only: Number "14" / Seconds "14s" / Percent "45%". - -- FULL ("14 Seconds") overflows a 20px icon (never fit, delisted with #5's - -- percent work — a saved FULL still renders until the user re-picks); the - -- combined "12s (45%)" is AD-bar-only for the same reason. - local durationFormatOptions = { NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], - _order = { "NUMBER", "SHORT", "PERCENT" } } - local durFormat = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Duration Format"], durationFormatOptions, db, "buffDurationFormat", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames(); GUI:RefreshCurrentPage() end), 55) - durFormat.disableOn = function(d) return not d.buffShowDuration end - -- Shared TextStyle control block (font/scale/outline/shadow/colour/anchor/ - -- offsets/justify). The static colour greys out while Color-by-Time owns it. - GUI:CreateTextControls(durationGroup, db, "buffDuration", { - parent = self.child, - include = { color = true }, - colorLabel = L["Duration Color"], - disableOn = function(d) return not d.buffShowDuration end, - colorDisableOn = function(d) return d.buffDurationColorByTime end, - onChange = function() DF:LightweightUpdateAuraDurationText("buff") end, - onDrag = function() DF:LightweightUpdateAuraDurationText("buff") end, - }) - local durColor = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Time Remaining"], db, "buffDurationColorByTime", function() self:RefreshStates(); DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) - durColor.disableOn = function(d) return not d.buffShowDuration end - AddColorsPageLink(durationGroup, self.child) - -- Hide Above can't compose with the Percent format (its threshold is seconds - -- banded into a seconds-sampled formatter — see GetDurationFormatFields). - local durHideAbove = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Above Threshold"], db, "buffDurationHideAboveEnabled", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) - durHideAbove.disableOn = function(d) return not d.buffShowDuration or DF:IsPercentDurationFormat(d.buffDurationFormat) end - local durHideAboveSlider = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Above (seconds)"], 1, 60, 1, db, "buffDurationHideAboveThreshold", nil, function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 55) - durHideAboveSlider.disableOn = function(d) return not d.buffShowDuration or not d.buffDurationHideAboveEnabled or DF:IsPercentDurationFormat(d.buffDurationFormat) end - local durHidePerm = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Duration on Permanent Auras"], db, "buffDurationHideOnPermanent", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) - durHidePerm.disableOn = function(d) return not d.buffShowDuration end - -- Grey the whole group when Buffs are off (composes with the per-control - -- buffShowDuration gates), matching Settings/Position/Grid. - durationGroup.disableChildrenOn = function(d) return not d.showBuffs end - Add(durationGroup, nil, 2) - - -- (No Expiring Indicator group: the pre-12.1 expiring border/tint was driven by a - -- ~3 Hz ticker reading remaining time, which is SECRET on 12.1. Removed 2026-07-25 - -- rather than left frosted. The 12.1-safe replacement is the DF.Expiration engine - -- (Features/Expiration.lua) + GUI:CreateExpirationControls, currently adopted by the - -- Aura Designer only -- rolling it out to these rows is a separate, unscheduled job.) - - -- ===== DURATION BAR ===== (12.1 factory rows only — the native - -- container drains the strip render-side; the legacy renderer has no bar) - -- - -- The collapsible section used to carry this predicate and hide the bar - -- with itself; with the section gone the box declares it directly. - local function HideDurationBar(d) return not DF:FactoryOwnsBuffRow(d) end - - -- Every bar edit routes through the factory drive: the sig split decides - -- Rebuild (enable/position/height/gap — layout reservation) vs in-place - -- restyle (texture/colours) — same callback either way. - local function BuffBarChanged() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end - - local durBarGroup = GUI:CreateSettingsGroup(self.child, 280) - durBarGroup.hideOn = HideDurationBar - -- "Duration Bar", not "Settings": the section that scoped that name is - -- gone, and the page already has a Settings box at the top. - durBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Bar"]), 40) - durBarGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows a bar on each icon that drains with the aura's remaining time."], 250), 30) - local buffBarEnable = durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Duration Bar"], db, "buffDurationBarEnabled", function() - self:RefreshStates() - BuffBarChanged() - end), 30) - buffBarEnable.keepEnabled = true - buffBarEnable.disableOn = function(d) return not d.showBuffs end - durBarGroup.disableChildrenOn = function(d) return not d.showBuffs or not d.buffDurationBarEnabled end - -- Where the bar sits, then what it looks like. One box rather than two, - -- matching Debuffs: every other optional element on the page is a single - -- box, and splitting only this one made the bar read as more of a feature - -- than its neighbours while taking up half of column 2. - durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, db, "buffDurationBarPosition", BuffBarChanged), 55) - durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 12, 1, db, "buffDurationBarHeight", nil, BuffBarChanged, true), 55) - durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Gap"], 0, 10, 1, db, "buffDurationBarGap", nil, BuffBarChanged, true), 55) - durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], DF:GetDurationBarColorModes(), db, "buffDurationBarColorMode", function() - self:RefreshStates() - BuffBarChanged() - end), 55) - local buffBarTex = durBarGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "buffDurationBarTexture", BuffBarChanged), 55) - local buffBarCol = durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "buffDurationBarColor", true, BuffBarChanged), 30) - -- A curve mode brings its own ramp texture and forces white, so these two do - -- nothing while it is selected - dim them rather than leave dead controls live. - buffBarTex.disableOn = function(d) return DF:IsDurationBarCurveMode(d.buffDurationBarColorMode) end - buffBarCol.disableOn = buffBarTex.disableOn - durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "buffDurationBarBGColor", true, BuffBarChanged), 30) - durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "buffDurationBarReverseFill", BuffBarChanged), 30) - Add(durBarGroup, nil, 2) - - -- ======================================== - -- ORDER & LIMITS (moved here from the old Aura Filters page) - -- ======================================== - -- These decide the ORDER of what already passed the filters, and cap it by - -- duration. Neither is a filter in the sense the Filters page means -- a - -- named set of spells you switch on -- so they belong with the bar they act - -- on. Which filters are active is on Aura Filters. - local BuffOrderChanged = function() - if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - end - - local buffOrderGroup = GUI:CreateSettingsGroup(self.child, 280) - buffOrderGroup:AddWidget(GUI:CreateHeader(self.child, L["Order & Limits"]), GUI.RowHeight.sectionHeader) - - local buffSortOptions = { - DEFAULT = L["Default (Slot Order)"], - TIME = L["Time Remaining"], - NAME = L["Alphabetical"], - APPLIED = L["Order Applied"], - _order = { "DEFAULT", "TIME", "NAME", "APPLIED" }, - } - buffOrderGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], buffSortOptions, db, "directBuffSortOrder", function() - BuffOrderChanged() - self:RefreshStates() -- Mine First greys while Sort Order = Default - end), 55) - - -- Sort refinements (native rows only — the legacy Lua scan doesn't read them) - local bfSortMine = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["My Auras First"], db, "directBuffSortMineFirst", BuffOrderChanged), 30) - bfSortMine.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end - bfSortMine.disableOn = function(d) return not DF:SortOrderSupportsMineFirst(d.directBuffSortOrder) end - bfSortMine.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] - local bfSortRev = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Order"], db, "directBuffSortReverse", BuffOrderChanged), 30) - bfSortRev.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end - bfSortRev.tooltip = L["Reverse the sort direction."] - - -- Native-only: max TOTAL duration filter (12.1 candidateFilters.maxDuration). - -- Hidden while the legacy render owns the row (not expressible there). - local bfMaxDur = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Long Buffs"], db, "buffMaxDurationEnabled", function() - BuffOrderChanged() - self:RefreshStates() - end), 30) - bfMaxDur.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end - bfMaxDur.tooltip = L["Hide buffs whose total duration is longer than the threshold - e.g. hour-long food and flask buffs. Buffs with no duration (permanent auras) are also hidden while this is on."] - local bfMaxDurSlider = buffOrderGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Longer Than (minutes)"], 1, 30, 1, db, "buffMaxDurationMinutes", nil, BuffOrderChanged), 55) - bfMaxDurSlider.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end - bfMaxDurSlider.disableOn = function(d) return not d.buffMaxDurationEnabled end - - -- Independent of Hide Long Buffs — but subsumed by it (a finite cap already - -- rejects duration-0 auras), hence the tooltip honesty. - local bfHidePerm = buffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Permanent Auras"], db, "buffHidePermanent", BuffOrderChanged), 30) - bfHidePerm.hideOn = function(d) return not DF:FactoryOwnsBuffRow(d) end - bfHidePerm.tooltip = L["Hide buffs with no duration, such as auras that last until cancelled. Hide Long Buffs also hides these while it is on."] - Add(buffOrderGroup, nil, 1) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_filterdesigner", label = L["Aura Filters"]}, - {pageId = "display_tooltips", label = L["Buff Tooltips"]}, - {pageId = "general_integrations", label = L["Integrations"]}, - {pageId = "auras_missingbuffs", label = L["Missing Buffs"]}, - }), 30, "both") - end) - - -- Auras > Debuffs (combined Layout + Appearance with collapsible sections) - local pageDebuffs = CreateSubTab("auras", "auras_debuffs", L["Debuffs"]) - BuildPage(pageDebuffs, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - -- "directDebuff" — same omission as Buffs above, plus ShowAll / DispellableMode. - Add(CreateCopyButton(self.child, {"debuff", "showDebuffs", "directDebuff"}, L["Debuffs"], "auras_debuffs"), 25, 2) - - - - local anchorOptions = { - CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], - TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], - } - - -- ===== DEDUPLICATION ===== - -- Same section, same position as the Buffs page: its own box at the top - -- of column 1, ahead of Settings. The two pages' dedupe toggles must be - -- findable in the same place. - local dedupGroup = GUI:CreateSettingsGroup(self.child, 280) - dedupGroup:AddWidget(GUI:CreateHeader(self.child, L["Deduplication"]), 40) - -- Inlined rather than reusing DebuffOrderChanged: that local is declared - -- with the Order & Limits box FURTHER DOWN this function, so naming it - -- here would read as a nil global — legal Lua, parses clean, and the - -- checkbox would just silently do nothing. - local dfDedup = GUI:CreateCheckbox(self.child, L["Hide Duplicate Debuffs"], db, "debuffDeduplicateDesigner", function() - if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - end) - dfDedup.tooltip = L["Hides debuffs that an Aura Designer group is already showing, so they don't appear twice."] - dedupGroup:AddWidget(dfDedup, 30) - Add(dedupGroup, nil, 1) - - -- Settings Group (col1) - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Debuffs"], db, "showDebuffs", function() - self:RefreshStates() - -- See Show Buffs above: re-scan auras on visible frames so a static - -- debuff hides/shows immediately instead of waiting for the next aura event. - DF:RefreshAllVisibleFrames() - end), 30) - local debuffMax = settingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Debuffs"], 0, 8, 1, db, "debuffMax", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) - debuffMax.disableOn = function(d) return not d.showDebuffs end - Add(settingsGroup, nil, 1) - - -- Appearance Group (col2) -- mirrors Buffs; see the note there. - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - local debuffSize = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 10, 40, 1, db, "debuffSize", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffSize.disableOn = function(d) return not d.showDebuffs end - local debuffScale = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "debuffScale", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffScale.disableOn = function(d) return not d.showDebuffs end - local debuffAlpha = appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.0, 1.0, 0.05, db, "debuffAlpha", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffAlpha.disableOn = function(d) return not d.showDebuffs end - Add(appearanceGroup, nil, 2) - - -- ===== IMPORTANT DEBUFFS (col2) ===== - -- Boss/role and priority debuffs already render as their OWN aura groups, and - -- those groups are declared first — so they already lead the row. Everything - -- here styles them so they also LOOK different without moving to a separate - -- placement. Every change is STRUCTURAL (region presence / group layout cell / - -- the group's init closure), so each callback must invalidate rather than - -- lightweight-reposition — same pair the Hide Duplicate Debuffs toggle uses. - local function ImportantChanged() - if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - end - local function ImportantOff(d) return not d.showDebuffs or not d.debuffImportantHighlight end - - local impGroup = GUI:CreateSettingsGroup(self.child, 280) - impGroup:AddWidget(GUI:CreateHeader(self.child, L["Important Debuffs"]), 40) - impGroup:AddWidget(GUI:CreateLabel(self.child, - L["Makes boss, role and priority debuffs stand out in the normal debuff row."], 250), 30) - local impOn = impGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Highlight Important Debuffs"], - db, "debuffImportantHighlight", ImportantChanged), 30) - impOn.disableOn = function(d) return not d.showDebuffs end - impOn.tooltip = L["Boss, role and priority debuffs already sort to the front of the row. This also makes them larger and marks them, so they read at a glance without needing their own placement."] - - -- CreateSlider(parent, label, min, max, step, db, key, callback, lightweightUpdate, - -- usePreviewMode, ...) — arg 8 is the release callback, arg 9 the per-drag-tick one - -- and arg 10 the boolean that arms it. - -- - -- ☠ NO LIGHTWEIGHT PATH ON ANY OF THE FOUR SLIDERS IN THIS SECTION, and it must - -- stay that way. Every key here feeds recStyleSig (Features/Auras.lua), which is - -- part of the STRUCTURAL signature — so each new value forces h:Rebuild: a - -- NativeBackend:teardown plus a fresh container and fresh buttons, per rendered - -- frame per visible unit. applyRecordStyle then creates a badge host frame and two - -- textures per styled button, and WoW never frees a frame. Wired to the drag tick, - -- a few seconds of dragging in a 20-man leaked frames by the thousand. The - -- "documented frame-leak case" note in AuraContainer.lua is about this path. - -- - -- The cost is that the preview moves on release rather than under the cursor. That - -- is the deliberate trade: one rebuild per adjustment is the price every other - -- structural setting pays, and it is bounded. - -- - -- ⚠ The better fix is to let badge geometry ride ApplyStyle instead of forcing a - -- rebuild — applyRecordStyle is already idempotent and safe to re-run — but the - -- record style is captured as an upvalue in the secure initializeFrame closure, so - -- a live read has to be plumbed through first. That is engine work, not a slider - -- change, and narrowing the signature WITHOUT it would leave these sliders writing - -- to the DB while nothing on screen moves. - local impScale = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Size Step"], 1.0, 2.0, 0.05, - db, "debuffImportantScale", ImportantChanged), 55) - impScale.disableOn = ImportantOff - impScale.tooltip = L["How much larger an important debuff renders. 1.00 keeps it the same size as the rest of the row."] - - local impBadge = impGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Corner Marker"], - db, "debuffImportantBadge", ImportantChanged), 30) - impBadge.disableOn = ImportantOff - impBadge.tooltip = L["A small marker on the corner of the icon. It survives being shrunk better than a colour change, and it does not compete with the dispel border."] - - local impBadgeSize = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Marker Size"], 6, 20, 1, - db, "debuffImportantBadgeSize", ImportantChanged), 55) - impBadgeSize.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end - - -- hasAlpha=false, and NO lightweight path: a colour change here rebuilds the - -- group (the tint is baked at initializeFrame), so there is nothing cheaper to - -- run on drag. Signature is (parent, label, db, key, hasAlpha, cb, lightCb, useLight). - -- Corner + nudge. Offsets are ADDED to a built-in overhang that pushes the badge - -- out of whichever corner is picked, so 0/0 is already a sensible resting place. - local badgePoints = { TOPRIGHT = L["Top Right"], TOPLEFT = L["Top Left"], - BOTTOMRIGHT = L["Bottom Right"], BOTTOMLEFT = L["Bottom Left"] } - local impBadgePt = impGroup:AddWidget(GUI:CreateDropdown(self.child, L["Marker Corner"], - badgePoints, db, "debuffImportantBadgePoint", ImportantChanged), 55) - impBadgePt.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end - - local impBadgeX = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Marker Offset X"], -20, 20, 1, - db, "debuffImportantBadgeX", ImportantChanged), 55) - impBadgeX.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end - - local impBadgeY = impGroup:AddWidget(GUI:CreateSlider(self.child, L["Marker Offset Y"], -20, 20, 1, - db, "debuffImportantBadgeY", ImportantChanged), 55) - impBadgeY.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end - - local impBadgeCol = impGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Marker Color"], - db, "debuffImportantBadgeColor", false, ImportantChanged, nil, false), 35) - impBadgeCol.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end - - local impMarkCol = impGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Marker Symbol Color"], - db, "debuffImportantMarkColor", false, ImportantChanged, nil, false), 35) - impMarkCol.disableOn = function(d) return ImportantOff(d) or not d.debuffImportantBadge end - Add(impGroup, nil, 2) - - -- Layout Group (col1) - local gridGroup = GUI:CreateSettingsGroup(self.child, 280) - gridGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) - local debuffWrap = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Per Row"], 1, 8, 1, db, "debuffWrap", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffWrap.disableOn = function(d) return not d.showDebuffs end - local debuffPaddingX = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing X"], -5, 10, 1, db, "debuffPaddingX", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffPaddingX.disableOn = function(d) return not d.showDebuffs end - local debuffPaddingY = gridGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing Y"], -5, 10, 1, db, "debuffPaddingY", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffPaddingY.disableOn = function(d) return not d.showDebuffs end - Add(gridGroup, nil, 1) - -- Position Group (col2) - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - local debuffAnchor = positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "debuffAnchor", nil), 55) - debuffAnchor.disableOn = function(d) return not d.showDebuffs end - local debuffGrowth = positionGroup:AddWidget(GUI:CreateGrowthControl(self.child, db, "debuffGrowth", nil), 155) - debuffGrowth.disableOn = function(d) return not d.showDebuffs end - local debuffOffsetX = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "debuffOffsetX", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffOffsetX.disableOn = function(d) return not d.showDebuffs end - local debuffOffsetY = positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "debuffOffsetY", nil, function() DF:LightweightUpdateAuraPosition("debuff") end, true), 55) - debuffOffsetY.disableOn = function(d) return not d.showDebuffs end - Add(positionGroup, nil, 1) - - local function InvalidateAndUpdate() - DF.debuffBorderCurve = nil - DF:UpdateAllFrames() - end - - -- Border Group (col1) - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - -- Full border toolkit via the unified helper (Stage 5.5 Phase 2). When - -- "Color by Dispel Type" (below) is ON, the border is forced SOLID and - -- recoloured per dispel type, so Style/Colour/Gradient here only take - -- effect when it's OFF (Size/Inset always apply). Border Animation is - -- intentionally omitted (same FPS rationale as the buff row). - GUI:CreateBorderControls(borderGroup, db, "debuff", { - parent = self.child, - include = { inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true }, - sizeMin = 0, sizeMax = 8, sizeStep = 1, - fullUpdate = function() if DF.UpdateAllFrames then DF:UpdateAllFrames() end end, - lightUpdate = function() DF:LightweightUpdateAuraBorder("debuff") end, - lightColors = function() DF:LightweightUpdateAuraBorder("debuff") end, - refreshStates = function() self:RefreshStates() end, - disableWhen = function(d) return not d.showDebuffs end, - }) - -- These two are added to the box BY HAND, so the toolkit's disableWhen - -- doesn't reach them — they carry the Debuffs-off grey themselves or the - -- box would half-grey. - local colorByType = borderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Dispel Type"], db, "debuffBorderColorByType", InvalidateAndUpdate), 30) - colorByType.disableOn = function(d) return not d.showDebuffs or not d.debuffShowBorder end - -- 12.1 rows: the native dispel ring's inset (+ inward / - outward halo; the - -- ring geometry is ours even though Blizzard tints it). Live via restyle. - local dispelInset = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Dispel Border Inset"], -8, 8, 1, db, "debuffDispelBorderInset", nil, function() DF:LightweightUpdateAuraBorder("debuff") end, true), 55) - dispelInset.disableOn = function(d) return not d.showDebuffs or not d.debuffBorderColorByType end - dispelInset.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end - dispelInset.tooltip = L["How far the dispel-type ring sits inside the icon edge. Negative values push it outward into a halo around the icon instead."] - -- Colors-page link right under "Color by Dispel Type": the dispel-type palette - -- lives on the account-wide Colors page (one shared set, also used by the Dispel - -- Overlay). Co-located with its toggle so it's obvious where to edit the colours. - local dispelColorsLink = GUI:CreateDispelColorsPageLink(self.child, 260) - borderGroup:AddWidget(dispelColorsLink, (dispelColorsLink.layoutHeight or 16) + 2) - -- Column 2 leads with Border and then runs the whole duration family -- - -- Duration, Duration Bar, Bar Style -- so the three boxes that configure - -- one thing sit together instead of being split across the page. - Add(borderGroup, nil, 2) - - -- Stack Count Group (col1) - local stackCountGroup = GUI:CreateSettingsGroup(self.child, 280) - stackCountGroup:AddWidget(GUI:CreateHeader(self.child, L["Stack Count"]), 40) - stackCountGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "debuffStackFont", function() DF:LightweightUpdateAuraStackText("debuff") end), 55) - stackCountGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "debuffStackScale", nil, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 55) - stackCountGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "debuffStackOutline", function() DF:LightweightUpdateAuraStackText("debuff") end), 55) - stackCountGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "debuffStackOutline", function() DF:LightweightUpdateAuraStackText("debuff") end), 30) - stackCountGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "debuffStackAnchor", function() DF:LightweightUpdateAuraStackText("debuff") end), 55) - stackCountGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "debuffStackX", nil, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 55) - stackCountGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "debuffStackY", nil, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 55) - stackCountGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "debuffStackColor", false, function() DF:LightweightUpdateAuraStackText("debuff") end, function() DF:LightweightUpdateAuraStackText("debuff") end, true), 30) - -- (No "Min Stacks to Show" — see the Buffs page for why it cannot exist on 12.1.) - -- Grey the whole group when Debuffs are off, matching Settings/Position/Grid. - stackCountGroup.disableChildrenOn = function(d) return not d.showDebuffs end - Add(stackCountGroup, nil, 1) - - -- Dispel Text Group (col1, under Stack Count) — the dispel-type letters - -- ("Ma", "Po", …), engine-written per aura (12.1 factory rows only; the - -- legacy renderer has no source for them). - -- ★ 2026-07-31: no longer requires Colorblind Mode. The bind passes - -- customDispelTextMap, which takes Blizzard's direct SetText path instead of - -- the CVar-gated one (DF:GetGameDispelTextMap, Frames/Border.lua) — so the - -- old caution note and the CVar caveat in the tooltip are gone with it. - -- Renamed from "Dispel Symbol" the same day: that read as the dispel ICON, - -- which is a different native feature. DB keys stay debuffDispelSymbol*. - local symbolGroup = GUI:CreateSettingsGroup(self.child, 280) - symbolGroup:AddWidget(GUI:CreateHeader(self.child, L["Dispel Text"]), 40) - local symbolEnable = symbolGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Dispel Text"], db, "debuffDispelSymbolEnabled", function() - self:RefreshStates() - -- Region presence is structural (create-once) — full re-drive rebuilds the row. - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - end), 30) - symbolEnable.tooltip = L["Shows a short letter code on each debuff for its dispel type — Ma for Magic, Po for Poison, and so on. Uses the game's own wording for your language."] - symbolEnable.keepEnabled = true - symbolEnable.disableOn = function(d) return not d.showDebuffs end - GUI:CreateTextControls(symbolGroup, db, "debuffDispelSymbol", { - parent = self.child, - include = { color = true }, - disableOn = function(d) return not d.debuffDispelSymbolEnabled end, - onChange = function() DF:InvalidateAuraLayout() end, - onDrag = function() DF:InvalidateAuraLayout() end, - }) - symbolGroup.disableChildrenOn = function(d) return not d.showDebuffs end - symbolGroup.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end - Add(symbolGroup, nil, 1) - - -- Duration Text Group (col2) - local durationGroup = GUI:CreateSettingsGroup(self.child, 280) - durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration"]), 40) - durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "debuffShowDuration", function() - self:RefreshStates() - DF:UpdateAllFrames() - end), 30) - -- Cooldown swipe (radial time-remaining) lives with Duration Text, not Border. - durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Cooldown Swipe"], db, "debuffHideSwipe", nil), 30) - -- Icon-sized formats only (see the buff page's Duration Format note). - local debuffDurationFormatOptions = { NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], - _order = { "NUMBER", "SHORT", "PERCENT" } } - local durFormat = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Duration Format"], debuffDurationFormatOptions, db, "debuffDurationFormat", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames(); GUI:RefreshCurrentPage() end), 55) - durFormat.disableOn = function(d) return not d.debuffShowDuration end - local durFont = durationGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "debuffDurationFont", nil), 55) - durFont.disableOn = function(d) return not d.debuffShowDuration end - local durScale = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "debuffDurationScale", nil, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 55) - durScale.disableOn = function(d) return not d.debuffShowDuration end - local durOutline = durationGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "debuffDurationOutline", function() DF:LightweightUpdateAuraDurationText("debuff") end), 55) - durOutline.disableOn = function(d) return not d.debuffShowDuration end - local durShadow = durationGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "debuffDurationOutline", function() DF:LightweightUpdateAuraDurationText("debuff") end), 30) - durShadow.disableOn = function(d) return not d.debuffShowDuration end - local durAnchor = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "debuffDurationAnchor", function() DF:LightweightUpdateAuraDurationText("debuff") end), 55) - durAnchor.disableOn = function(d) return not d.debuffShowDuration end - local durX = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "debuffDurationX", nil, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 55) - durX.disableOn = function(d) return not d.debuffShowDuration end - local durY = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "debuffDurationY", nil, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 55) - durY.disableOn = function(d) return not d.debuffShowDuration end - local durColorPick = durationGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Duration Color"], db, "debuffDurationColor", false, function() DF:LightweightUpdateAuraDurationText("debuff") end, function() DF:LightweightUpdateAuraDurationText("debuff") end, true), 30) - durColorPick.disableOn = function(d) return not d.debuffShowDuration or d.debuffDurationColorByTime end - local durColor = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Time Remaining"], db, "debuffDurationColorByTime", function() self:RefreshStates(); DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) - durColor.disableOn = function(d) return not d.debuffShowDuration end - AddColorsPageLink(durationGroup, self.child) - -- Hide Above can't compose with the Percent format (see the buff page). - local durHideAbove = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Above Threshold"], db, "debuffDurationHideAboveEnabled", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) - durHideAbove.disableOn = function(d) return not d.debuffShowDuration or DF:IsPercentDurationFormat(d.debuffDurationFormat) end - local durHideAboveSlider = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Above (seconds)"], 1, 60, 1, db, "debuffDurationHideAboveThreshold", nil, function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 55) - durHideAboveSlider.disableOn = function(d) return not d.debuffShowDuration or not d.debuffDurationHideAboveEnabled or DF:IsPercentDurationFormat(d.debuffDurationFormat) end - local durHidePerm = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Duration on Permanent Auras"], db, "debuffDurationHideOnPermanent", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 30) - durHidePerm.disableOn = function(d) return not d.debuffShowDuration end - -- Grey the whole group when Debuffs are off (composes with the per-control - -- debuffShowDuration gates), matching Settings/Position/Grid. - durationGroup.disableChildrenOn = function(d) return not d.showDebuffs end - Add(durationGroup, nil, 2) - - -- ===== DURATION BAR ===== (12.1 factory rows only — mirrors the Buffs - -- page's block; see there for the sig-split routing note) - -- - -- The collapsible section used to carry this predicate and hide the bar - -- with itself; with the section gone the box declares it directly. - local function HideDurationBar(d) return not DF:FactoryOwnsDebuffRow(d) end - - local function DebuffBarChanged() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end - - local durBarGroup = GUI:CreateSettingsGroup(self.child, 280) - durBarGroup.hideOn = HideDurationBar - durBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Bar"]), 40) - durBarGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows a bar on each icon that drains with the aura's remaining time."], 250), 30) - local debuffBarEnable = durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Duration Bar"], db, "debuffDurationBarEnabled", function() - self:RefreshStates() - DebuffBarChanged() - end), 30) - debuffBarEnable.keepEnabled = true - debuffBarEnable.disableOn = function(d) return not d.showDebuffs end - durBarGroup.disableChildrenOn = function(d) return not d.showDebuffs or not d.debuffDurationBarEnabled end - -- Where the bar sits, then what it looks like. One box rather than two: - -- every other optional element on this page (Stack Count, Dispel Text) - -- is a single box, and splitting only this one into geometry + style - -- made the bar read as more of a feature than its neighbours while - -- taking up half of column 2. - durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, db, "debuffDurationBarPosition", DebuffBarChanged), 55) - durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 12, 1, db, "debuffDurationBarHeight", nil, DebuffBarChanged, true), 55) - durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Gap"], 0, 10, 1, db, "debuffDurationBarGap", nil, DebuffBarChanged, true), 55) - durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], DF:GetDurationBarColorModes(), db, "debuffDurationBarColorMode", function() - self:RefreshStates() - DebuffBarChanged() - end), 55) - local debuffBarTex = durBarGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "debuffDurationBarTexture", DebuffBarChanged), 55) - local debuffBarCol = durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "debuffDurationBarColor", true, DebuffBarChanged), 30) - -- A curve mode brings its own ramp texture and forces white, so these two do - -- nothing while it is selected - dim them rather than leave dead controls live. - debuffBarTex.disableOn = function(d) return DF:IsDurationBarCurveMode(d.debuffDurationBarColorMode) end - debuffBarCol.disableOn = debuffBarTex.disableOn - durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "debuffDurationBarBGColor", true, DebuffBarChanged), 30) - durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "debuffDurationBarReverseFill", DebuffBarChanged), 30) - Add(durBarGroup, nil, 2) - - -- See Also links - -- ======================================== - -- ORDER & LIMITS (moved here from the old Aura Filters page) - -- ======================================== - -- Ordering and the duration cap act on what already passed the filters, so - -- they live with the bar. Which debuff categories are active is on Aura - -- Filters. The Dispellable MODE dropdown comes with them: it refines a - -- category rather than being one, and the category row's tooltip on the - -- Filters page points here for it. - local DebuffOrderChanged = function() - if DF.RebuildDirectFilterStrings then DF:RebuildDirectFilterStrings() end - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - end - - local debuffOrderGroup = GUI:CreateSettingsGroup(self.child, 280) - debuffOrderGroup:AddWidget(GUI:CreateHeader(self.child, L["Order & Limits"]), GUI.RowHeight.sectionHeader) - - local debuffSortOptions = { - DEFAULT = L["Default (Slot Order)"], - TIME = L["Time Remaining"], - NAME = L["Alphabetical"], - APPLIED = L["Order Applied"], - _order = { "DEFAULT", "TIME", "NAME", "APPLIED" }, - } - debuffOrderGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], debuffSortOptions, db, "directDebuffSortOrder", function() - DebuffOrderChanged() - self:RefreshStates() - end), 55) - - local dfSortMine = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["My Auras First"], db, "directDebuffSortMineFirst", DebuffOrderChanged), 30) - dfSortMine.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end - dfSortMine.disableOn = function(d) return not DF:SortOrderSupportsMineFirst(d.directDebuffSortOrder) end - dfSortMine.tooltip = L["Sort your own auras before other players'. Unavailable on Default (which already shows yours first) and on Order Applied (which keeps one fixed order)."] - local dfSortRev = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Order"], db, "directDebuffSortReverse", DebuffOrderChanged), 30) - dfSortRev.hideOn = function(d) return not DF:FactoryOwnsDebuffRow(d) end - dfSortRev.tooltip = L["Reverse the sort direction."] - - -- Which dispels count for the Dispellable Debuffs category. Greys rather - -- than hides while that category is off, so you can see what you would be - -- turning back on -- and it is inert while All Debuffs is on. - local dfDispelMode = debuffOrderGroup:AddWidget(GUI:CreateDropdown(self.child, L["Dispellable Debuffs"], { - PLAYER = L["Dispellable By Me"], - ALL = L["All Dispellable"], - ANY = L["Any Dispel Type"], - _order = { "PLAYER", "ALL", "ANY" }, - }, db, "directDebuffDispellableMode", DebuffOrderChanged), 55) - dfDispelMode.disableOn = function(d) - return d.directDebuffShowAll or not d.debuffFilterDispellable - end - dfDispelMode.tooltip = L["Dispellable By Me: only debuffs you can dispel. All Dispellable: any debuff that can be dispelled. Any Dispel Type: every debuff with a dispel type, even ones that cannot be dispelled."] - - -- Works in ALL-debuffs mode too (single maxDuration record) — only Keep - -- Important needs the category filters (boolean flags can't be negated on - -- the ALL record), so THAT toggle alone greys while All Debuffs is on. - local function HideDebuffMaxDurControls(d) - return not DF:FactoryOwnsDebuffRow(d) - end - local dfMaxDur = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Long Debuffs"], db, "debuffMaxDurationEnabled", function() - DebuffOrderChanged() - self:RefreshStates() - end), 30) - dfMaxDur.hideOn = HideDebuffMaxDurControls - dfMaxDur.tooltip = L["Hide debuffs whose total duration is longer than the threshold. Debuffs with no duration (permanent auras) are also hidden while this is on."] - local dfMaxDurSlider = debuffOrderGroup:AddWidget(GUI:CreateSlider(self.child, L["Hide Longer Than (minutes)"], 1, 30, 1, db, "debuffMaxDurationMinutes", nil, DebuffOrderChanged), 55) - dfMaxDurSlider.hideOn = HideDebuffMaxDurControls - dfMaxDurSlider.disableOn = function(d) return not d.debuffMaxDurationEnabled end - - local dfKeepImportant = debuffOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Keep important debuffs"], db, "debuffMaxDurationKeepImportant", DebuffOrderChanged), 30) - dfKeepImportant.hideOn = HideDebuffMaxDurControls - dfKeepImportant.disableOn = function(d) - return d.directDebuffShowAll or not d.debuffMaxDurationEnabled - end - dfKeepImportant.tooltip = L["Boss, Role, and Priority debuffs stay visible even when their duration is over the threshold."] - Add(debuffOrderGroup, nil, 1) - - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_filterdesigner", label = L["Aura Filters"]}, - {pageId = "display_tooltips", label = L["Debuff Tooltips"]}, - {pageId = "general_integrations", label = L["Integrations"]}, - {pageId = "auras_dispel", label = L["Dispel Overlay"]}, - }), 30, "both") - end) - - - -- Auras > Missing Buffs - local pageMissingBuffs = CreateSubTab("auras", "auras_missingbuffs", L["Missing Buffs"]) - BuildPage(pageMissingBuffs, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"missingBuff"}, L["Missing Buffs"], "auras_missingbuffs"), 25, 2) - - - -- Dependent controls GREY OUT (disabled-in-place) when the feature is off. - local function HideMissingBuffOptions(d) - return not d.missingBuffIconEnabled - end - - -- Manual-mode buffs HIDE when auto-detect is on (variant gate); they GREY - -- via the group's disableChildrenOn when the feature itself is disabled. - local function HideManualBuffVariant(d) - return d.missingBuffClassDetection - end - - -- 12.1 factory path: settings apply through the version-gated drive, so a - -- change must bump the aura layout version (InvalidateAuraLayout re-drives - -- every factory widget, missing-buff strip included). Legacy path unchanged. - local function refreshMissing() - if DF.FactoryOwnsMissingBuff and DF:FactoryOwnsMissingBuff(db) then - DF:InvalidateAuraLayout() - end - if DF.UpdateAllMissingBuffIcons then DF:UpdateAllMissingBuffIcons() end - end - - local anchorOptions = { - ["TOPLEFT"]= L["Top Left"], ["TOP"]= L["Top"], ["TOPRIGHT"]= L["Top Right"], - ["LEFT"]= L["Left"], ["CENTER"]= L["Center"], ["RIGHT"]= L["Right"], - ["BOTTOMLEFT"]= L["Bottom Left"], ["BOTTOM"]= L["Bottom"], ["BOTTOMRIGHT"]= L["Bottom Right"], - } - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows icon when party members are missing raid buffs."], 250), 30) - -- 12.1 (factory path): the read-free widget works in combat + Mythic+ and - -- shows EVERY tracked-and-missing buff (the legacy "first missing only" - -- priority pick needed a cross-aura read). Legacy path keeps the caveat. - local mbOwns = DF.FactoryOwnsMissingBuff and DF:FactoryOwnsMissingBuff(db) - local mPlusWarn = GUI:CreateInfoBanner(self.child, { tone = mbOwns and "info" or "caution" }) - mPlusWarn:SetText(mbOwns - and L["Updates instantly, including in combat and Mythic+. Each tracked buff that is missing shows its own icon."] - or L["Does NOT work in Mythic+ keystones. In combat, results may be slightly delayed."]) - settingsGroup:AddWidget(mPlusWarn, 60) - local missingBuffEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Missing Buff Icon"], db, "missingBuffIconEnabled", function() - self:RefreshStates() - refreshMissing() - end), 30) - missingBuffEnable.keepEnabled = true - settingsGroup.disableChildrenOn = HideMissingBuffOptions - local mbAutoDetect = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Auto-detect (your class's buff)"], db, "missingBuffClassDetection", function() - self:RefreshStates() - refreshMissing() - end), 30) - mbAutoDetect.tooltip = L["Watches whichever raid buff your own class provides, and follows you when you change character. Turn it off to pick the buffs to watch by hand below."] - local mbHideFromBar = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Raid Buffs from Buff Bar"], db, "missingBuffHideFromBar", function() - -- Factory: the exclusion is a structural candidate-filter on the BUFF row — - -- refreshMissing's InvalidateAuraLayout re-drives it (sig change -> Rebuild). - refreshMissing() - DF:UpdateAllAuras() - end), 30) - mbHideFromBar.tooltip = L["Stops the raid buffs tracked here from also taking up a slot in the normal buff row, so the missing-buff icon is the only place they appear."] - -- (No Debug Mode checkbox: its trace narrated the legacy UnitHasBuff scan, which - -- never runs on the read-free 12.1 widget -- presence is never known to Lua, so - -- there is nothing to print. Removed 2026-07-25 as its own comment long proposed.) - Add(settingsGroup, nil, 1) - - -- ===== BUFFS TO CHECK GROUP (Column 1) ===== - local buffsGroup = GUI:CreateSettingsGroup(self.child, 280) - buffsGroup:AddWidget(GUI:CreateHeader(self.child, L["Buffs to Check (Manual Mode)"]), 40) - buffsGroup:AddWidget(GUI:CreateLabel(self.child, L["When auto-detect is OFF, select which raid buffs to monitor manually."], 250), 35) - buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Arcane Intellect (Mage)"], db, "missingBuffCheckIntellect", function() - refreshMissing() - end), 30) - buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Power Word: Fortitude (Priest)"], db, "missingBuffCheckStamina", function() - refreshMissing() - end), 30) - buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Battle Shout (Warrior)"], db, "missingBuffCheckAttackPower", function() - refreshMissing() - end), 30) - buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Mark of the Wild (Druid)"], db, "missingBuffCheckVersatility", function() - refreshMissing() - end), 30) - buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Skyfury (Shaman)"], db, "missingBuffCheckSkyfury", function() - refreshMissing() - end), 30) - buffsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Blessing of the Bronze (Evoker)"], db, "missingBuffCheckBronze", function() - refreshMissing() - end), 30) - buffsGroup.hideOn = HideManualBuffVariant - buffsGroup.disableChildrenOn = HideMissingBuffOptions - Add(buffsGroup, nil, 1) - - -- ===== APPEARANCE GROUP (Column 2) ===== - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - appearanceGroup.disableChildrenOn = HideMissingBuffOptions - appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 12, 48, 1, db, "missingBuffIconSize", function() - refreshMissing() - end, function() DF:LightweightUpdateMissingBuff() end, true), 55) - appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 3.0, 0.1, db, "missingBuffIconScale", function() - refreshMissing() - end, function() DF:LightweightUpdateMissingBuff() end, true), 55) - appearanceGroup:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "missingBuffIconFrameLevel", function() - refreshMissing() - end, function() DF:LightweightUpdateFrameLevel("missingBuff") end, true)), 55) - Add(appearanceGroup, nil, 2) - - -- ===== POSITION GROUP (Column 1) ===== - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - positionGroup.disableChildrenOn = HideMissingBuffOptions - positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "missingBuffIconAnchor", function() - refreshMissing() - end), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "missingBuffIconX", function() - refreshMissing() - end, function() DF:LightweightUpdateMissingBuff() end, true), 55) - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "missingBuffIconY", function() - refreshMissing() - end, function() DF:LightweightUpdateMissingBuff() end, true), 55) - Add(positionGroup, nil, 1) - - -- ===== BORDER GROUP (Column 2) ===== - -- Stage 4.1: hand-rolled border block replaced by the unified helper. - -- include set tailored for a "needs attention" alert: alpha / inset / - -- offset / blendMode / gradient / shadow / animate (matches the - -- Defensive Icon — Border Offset nudges the band relative to the icon). - -- Class/Role colour offered too: the missing-buff icon sits on a unit - -- frame, so its border can communicate WHOSE buff is missing at a glance. - -- Skipped: colour-by-time / colour-by-type (no aura-state context here). - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - GUI:CreateBorderControls(borderGroup, db, "missingBuffIcon", { - parent = self.child, - include = { alpha = true, inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, animate = true, - classColor = true, roleColor = true }, - fullUpdate = function() refreshMissing() end, - lightUpdate = function() DF:LightweightUpdateMissingBuff() end, - lightColors = function() DF:LightweightUpdateMissingBuffBorderColor() end, - refreshStates = function() self:RefreshStates() end, - sizeMin = 0, sizeMax = 6, sizeStep = 1, -- 0 = animation-only (no solid edge) - }) - -- No hideWhen: the group gate below is what handles the feature being - -- off, and it GREYS like every other box on this page. (This call used to - -- pass both, so the controls vanished before the grey could show.) - borderGroup.disableChildrenOn = HideMissingBuffOptions - Add(borderGroup, nil, 2) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_buffs", label = L["Buffs"]}, - }), 30, "both") - end) - - -- Auras > Defensive Icon - local pageDefensiveIcon = CreateSubTab("auras", "auras_defensiveicon", L["Defensive Icon"]) - -- 12.1: defensive icons now render through DF.AuraContainer (native BIG_DEFENSIVE / - -- EXTERNAL_DEFENSIVE filters); the legacy path stays as a secret-hardened fallback, so - -- the page is usable — no whole-page banner, and nothing is blocked (frame level IS - -- honored, via the container's frameLevelOffset). Known gaps the factory doesn't - -- reproduce yet (not cleanly addressable, left as-is): border animation (inlined in the - -- shared border helper) and CENTER growth (a dropdown option that falls back to RIGHT). - BuildPage(pageDefensiveIcon, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top. defensiveFilterSelection is an exact-key entry - -- (prefix matcher, see Profile.lua) so the category selection edited on - -- this page rides this page's Copy/Sync/Reset. It is also registered on - -- the Aura Filters page — overlap is fine, both DeepCopy the same value. - -- "defensiveBar" covers the row's Layout box (Max / Growth / Spacing / Wrap), - -- which none of the other prefixes reached. - Add(CreateCopyButton(self.child, {"defensiveIcon", "defensiveFilterSelection", "defensiveSortOrder", "defensiveDurationBar", "defensiveBar"}, L["Defensive Icon"], "auras_defensiveicon"), 25, 2) - - local anchorOptions = { - CENTER= L["Center"], TOP= L["Top"], BOTTOM= L["Bottom"], LEFT= L["Left"], RIGHT= L["Right"], - TOPLEFT= L["Top Left"], TOPRIGHT= L["Top Right"], BOTTOMLEFT= L["Bottom Left"], BOTTOMRIGHT= L["Bottom Right"], - } - - -- Dependent controls GREY OUT (disabled-in-place) when the feature is off. - local function HideDefensiveIconOptions(d) - return not d.defensiveIconEnabled - end - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows an icon when party members have a defensive cooldown active (Pain Suppression, Ironbark, etc.)."], 250), 45) - local defensiveEnable = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Defensive Icon"], db, "defensiveIconEnabled", function() - self:RefreshStates() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 30) - defensiveEnable.keepEnabled = true - settingsGroup.disableChildrenOn = HideDefensiveIconOptions - - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Cooldown Swipe"], db, "defensiveIconHideSwipe", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 30) - Add(settingsGroup, nil, 1) - - -- ===== LAYOUT GROUP (Column 1) ===== - local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) - layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout"]), 40) - layoutGroup:AddWidget(GUI:CreateLabel(self.child, L["Controls how multiple defensive icons are arranged."], 250), 45) - layoutGroup.disableChildrenOn = HideDefensiveIconOptions - - layoutGroup:AddWidget(GUI:CreateGrowthControl(self.child, db, "defensiveBarGrowth", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 155) - layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Icons"], 1, 5, 1, db, "defensiveBarMax", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, nil, true), 55) - - -- Native rows only — the legacy fallback keeps its own fixed order. - local defSortOptions = { - DEFAULT = L["Default (Slot Order)"], - TIME = L["Most Urgent"], - EXTERNALS = L["Externals First"], - } - local defSortDrop = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], defSortOptions, db, "defensiveSortOrder", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 55) - defSortDrop.hideOn = function(d) return not DF:FactoryOwnsDefensiveRow(d) end - defSortDrop.tooltip = L["Externals First: defensives cast on this player by others show first, their own last. Most Urgent: soonest to expire first."] - - local defWrap = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Per Row"], 1, 5, 1, db, "defensiveBarWrap", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, nil, true), 55) - -- Greys out on vertical-primary growth, where the native row-primary flow renders a - -- single column and there is nothing for a per-row count to do. Normal contextual - -- state via the grey seam, NOT a 12.1 frost — the control works horizontally, and the - -- blocked registry is for things the game genuinely cannot do. Mirrors the Buffs page, - -- including its 68914 re-verification of the flow-layout options. - defWrap.disableOn = function(d) - local g = d.defensiveBarGrowth or "" - -- Vertical-primary AND vertical-centred growth both render a single column. - return DF:FactoryOwnsDefensiveRow(d) and (g:sub(1, 2) == "UP" or g:sub(1, 4) == "DOWN" - or g == "CENTER_LEFT" or g == "CENTER_RIGHT") - end - - layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], -10, 10, 1, db, "defensiveBarSpacing", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) - - Add(layoutGroup, nil, 1) - - -- ===== APPEARANCE GROUP (Column 2) ===== - local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) - appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) - appearanceGroup.disableChildrenOn = HideDefensiveIconOptions - - appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 12, 48, 1, db, "defensiveIconSize", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) - - appearanceGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 4.0, 0.1, db, "defensiveIconScale", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) - - appearanceGroup:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "defensiveIconFrameLevel", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, function() DF:LightweightUpdateFrameLevel("defensive") end, true)), 55) - - Add(appearanceGroup, nil, 2) - - -- ===== POSITION GROUP (Column 1) ===== - local positionGroup = GUI:CreateSettingsGroup(self.child, 280) - positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) - positionGroup.disableChildrenOn = HideDefensiveIconOptions - - positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "defensiveIconAnchor", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 55) - - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "defensiveIconX", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) - - positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "defensiveIconY", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end, function() DF:LightweightUpdateDefensiveIcons() end, true), 55) - Add(positionGroup, nil, 1) - - -- ===== BORDER GROUP (Column 2) ===== - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - - -- Canonical border controls via the unified helper. include opts in - -- inset / offset / blendMode / gradient / shadow on top of the - -- always-present Show / Style / Texture / Size / Colour. Inset moves - -- the border edges inward (positive) or outward (negative) relative - -- to the icon's bounds — independent of borderSize (thickness) and - -- independent of the artwork's own inset. - GUI:CreateBorderControls(borderGroup, db, "defensiveIcon", { - parent = self.child, - -- Class/Role colour makes sense here: at a glance, the border - -- communicates WHO is using the defensive cooldown (their class - -- or role) without the user having to read the icon. (Animation is - -- not offered: the defensive icon is a container button, and 12.1 - -- forbids driving its border while auras are secret — see - -- AuraContainer's animation chokepoint.) - include = { inset = true, offset = true, blendMode = true, - gradient = true, shadow = true, alpha = true, - classColor = true, roleColor = true }, - fullUpdate = function() if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end end, - lightUpdate = function() DF:LightweightUpdateDefensiveIcons() end, - lightColors = function() DF:LightweightUpdateDefensiveIconColors() end, - refreshStates = function() self:RefreshStates() end, - }) - -- No hideWhen: the group gate below is what handles the feature being - -- off, and it GREYS like every other box on this page. (This call used to - -- pass both, so the controls vanished before the grey could show.) - borderGroup.disableChildrenOn = HideDefensiveIconOptions - Add(borderGroup, nil, 2) - - -- ===== DEFENSIVE FILTERS GROUP (Column 2) ===== - -- Category filter selection for the defensive row (Filter Registry - -- presets + custom filters). Mirrors the Aura Filters page's buff - -- selection list. Each row toggles a key inside - -- db.defensiveFilterSelection — always mutate the inner tables in - -- place (the aura pipeline holds references to them; never reassign). - -- No Show All / Only Mine here: the defensive row resolves with - -- showAll hard-false and has no such keys (see BuildDefensiveRowConfig). - do - local filterGroup = GUI:CreateSettingsGroup(self.child, 280) - filterGroup:AddWidget(GUI:CreateHeader(self.child, L["Defensive Filters"]), 40) - filterGroup.disableChildrenOn = HideDefensiveIconOptions - - filterGroup:AddWidget(GUI:CreateLabel(self.child, "|cff888888" .. L["Enabled filters are combined \226\128\148 buffs matching any selected filter will be shown."] .. "|r", 250), 35) - - -- Rebuild the native filter strings and re-drive the container rows - -- (same pair as the Aura Filters page's DirectFilterChanged — this - -- page has no local equivalent). - local function DefensiveFilterChanged() - if DF.RebuildDirectFilterStrings then - DF:RebuildDirectFilterStrings() - end - if DF.InvalidateAuraLayout then - DF:InvalidateAuraLayout() - end - end - - local R = DF.FilterRegistry - local function SelectionCheckbox(labelText, getSel, setSel) - return filterGroup:AddWidget(GUI:CreateCheckbox(self.child, labelText, nil, nil, DefensiveFilterChanged, getSel, setSel), 30) - end - - for _, cat in ipairs(R.Categories) do - local key = cat.key - local enabled, total = R:PresetCounts(key) - local counts = R:IsPresetModified(key) - and format("(%d/%d, %s)", enabled, total, L["Modified"]) - or format("(%d/%d)", enabled, total) - SelectionCheckbox(format("%s |cff888888%s|r", L[cat.name], counts), - function() return db.defensiveFilterSelection.presets[key] or false end, - function(v) db.defensiveFilterSelection.presets[key] = v or nil end) - end - - -- Custom filters, sorted by name for a stable order (the store is id-keyed) - local sortedCustoms = {} - for cfId in pairs(R:GetStore().customFilters) do - sortedCustoms[#sortedCustoms + 1] = cfId - end - table.sort(sortedCustoms, function(a, b) - local fa, fb = R:GetCustomFilter(a), R:GetCustomFilter(b) - local na, nb = (fa and fa.name or ""), (fb and fb.name or "") - if na ~= nb then return na < nb end - return a < b - end) - for _, cfId in ipairs(sortedCustoms) do - local f = R:GetCustomFilter(cfId) - SelectionCheckbox(format("%s |c%s(%s)|r", f.name or cfId, GUI:ToneHex("info"), L["Custom"]), - function() return db.defensiveFilterSelection.customs[cfId] or false end, - function(v) db.defensiveFilterSelection.customs[cfId] = v or nil end) - end - - -- Complement bucket: buffs that belong to no category - SelectionCheckbox(L["Uncategorised Buffs"], - function() return db.defensiveFilterSelection.uncategorised end, - function(v) db.defensiveFilterSelection.uncategorised = v and true or false end) - - local defManage = filterGroup:AddWidget(GUI:CreateButton(self.child, L["Manage Filters"], 140, 22, function() - if GUI.SelectTab and GUI.Pages and GUI.Pages["auras_filterdesigner"] then - GUI.SelectTab("auras_filterdesigner") - end - end), 30) - defManage.disableOn = function() return not (GUI.Pages and GUI.Pages["auras_filterdesigner"]) end - - -- The page build is cached across tab switches, but preset counts and - -- the custom-filter list can change while this page is hidden (Filter - -- Designer edits). On show, invalidate the page cache when the registry - -- signature moved so RefreshCached() rebuilds fresh rows instead of - -- serving stale ones (same idiom as the Aura Filters page). - local function RegistrySignature() - local parts = {} - for _, cat in ipairs(R.Categories) do - local enabled, total = R:PresetCounts(cat.key) - parts[#parts + 1] = format("%s:%d/%d%s", cat.key, enabled, total, - R:IsPresetModified(cat.key) and "*" or "") - end - for cfId, f in pairs(R:GetStore().customFilters) do - parts[#parts + 1] = cfId .. "=" .. (f.name or "") - end - table.sort(parts) - return table.concat(parts, ";") - end - self.dfDefFilterSignature = RegistrySignature() - if not self.dfDefFilterSigHooked then - self.dfDefFilterSigHooked = true - self:HookScript("OnShow", function(page) - if page.dfDefFilterSignature ~= RegistrySignature() then - page:Invalidate() - end - end) - end - - Add(filterGroup, nil, 2) - end - - -- ===== DURATION GROUP (Column 1) ===== - local durationGroup = GUI:CreateSettingsGroup(self.child, 280) - durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Text"]), 40) - - durationGroup.disableChildrenOn = HideDefensiveIconOptions - durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "defensiveIconShowDuration", function() - self:RefreshStates() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 30) - - -- Sub-controls HIDE when Show Duration is off (variant gate); they GREY - -- via the group's disableChildrenOn when the feature itself is disabled. - local function HideDefensiveDurationOptions(d) - return not d.defensiveIconShowDuration - end - - -- Duration Format (PTR-7 #5): previously hardcoded NUMBER; icon-sized - -- formats only (see the buff page's Duration Format note). No Hide Above - -- on this page, so no percent-grey needed. - local defDurFormatOptions = { NUMBER = L["Number"], SHORT = L["Seconds"], PERCENT = L["Percent"], - _order = { "NUMBER", "SHORT", "PERCENT" } } - local defDurFormat = durationGroup:AddWidget(GUI:CreateDropdown(self.child, L["Duration Format"], defDurFormatOptions, db, "defensiveIconDurationFormat", function() DF:InvalidateAuraLayout(); DF:UpdateAllFrames() end), 55) - defDurFormat.hideOn = HideDefensiveDurationOptions - - -- Shared TextStyle control block (font/scale/outline/shadow/colour/anchor/ - -- offsets/justify). The offsets/anchor honor the existing defensiveIconDurationX/Y - -- keys (previously config-only); the static colour greys while Color-by-Time owns it. - GUI:CreateTextControls(durationGroup, db, "defensiveIconDuration", { - parent = self.child, - include = { color = true }, - colorLabel = L["Duration Color"], - hideOn = HideDefensiveDurationOptions, - colorDisableOn = function(d) return d.defensiveIconDurationColorByTime end, - onChange = function() if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end end, - onDrag = function() DF:LightweightUpdateDefensiveIcons() end, - }) - - local diDurColorByTime = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Color by Time Remaining"], db, "defensiveIconDurationColorByTime", function() - self:RefreshStates() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 30) - diDurColorByTime.hideOn = HideDefensiveDurationOptions - local diColorsLink = AddColorsPageLink(durationGroup, self.child) - diColorsLink.hideOn = HideDefensiveDurationOptions - - local diDurHidePerm = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Duration on Permanent Auras"], db, "defensiveIconDurationHideOnPermanent", function() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end), 30) - diDurHidePerm.hideOn = HideDefensiveDurationOptions - - Add(durationGroup, nil, 1) - - -- (The old "Duration Position" group is gone: CreateTextControls above already - -- renders Anchor + Offset X/Y on the same defensiveIconDurationX/Y keys — the - -- separate group was a duplicate left behind by the TextStyle conversion.) - - -- ===== DURATION BAR GROUP (Column 1) ===== (12.1 factory rows only — - -- mirrors the Buffs page's block; UpdateAllDefensiveBars bumps the layout - -- version, and the sig split routes Rebuild vs in-place restyle) - local durBarGroup = GUI:CreateSettingsGroup(self.child, 280) - durBarGroup.hideOn = function(d) return not DF:FactoryOwnsDefensiveRow(d) end - durBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Bar"]), 40) - durBarGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows a bar on each icon that drains with the aura's remaining time."], 250), 30) - local function DefBarChanged() - if DF.UpdateAllDefensiveBars then DF:UpdateAllDefensiveBars() end - end - local defBarEnable = durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Duration Bar"], db, "defensiveDurationBarEnabled", function() - self:RefreshStates() - DefBarChanged() - end), 30) - defBarEnable.keepEnabled = true - defBarEnable.disableOn = HideDefensiveIconOptions - durBarGroup.disableChildrenOn = function(d) return not d.defensiveIconEnabled or not d.defensiveDurationBarEnabled end - durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Position"], { BOTTOM = L["Bottom"], TOP = L["Top"] }, db, "defensiveDurationBarPosition", DefBarChanged), 55) - durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 1, 12, 1, db, "defensiveDurationBarHeight", nil, DefBarChanged, true), 55) - durBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Gap"], 0, 10, 1, db, "defensiveDurationBarGap", nil, DefBarChanged, true), 55) - durBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Color Mode"], DF:GetDurationBarColorModes(), db, "defensiveDurationBarColorMode", function() - self:RefreshStates() - DefBarChanged() - end), 55) - local defBarTex = durBarGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "defensiveDurationBarTexture", DefBarChanged), 55) - local defBarCol = durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Bar Color"], db, "defensiveDurationBarColor", true, DefBarChanged), 30) - -- A curve mode brings its own ramp texture and forces white, so these two do - -- nothing while it is selected - dim them rather than leave dead controls live. - defBarTex.disableOn = function(d) return DF:IsDurationBarCurveMode(d.defensiveDurationBarColorMode) end - defBarCol.disableOn = defBarTex.disableOn - durBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "defensiveDurationBarBGColor", true, DefBarChanged), 30) - durBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Reverse Fill"], db, "defensiveDurationBarReverseFill", DefBarChanged), 30) - Add(durBarGroup, nil, 1) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_buffs", label = L["Buffs"]}, - {pageId = "auras_debuffs", label = L["Debuffs"]}, - {pageId = "auras_filterdesigner", label = L["Aura Filters"]}, - {pageId = "general_integrations", label = L["Integrations"]}, - }), 30, "both") - end) - - -- ======================================== - -- CATEGORY: Indicators - -- ======================================== - CreateCategory("indicators", L["Indicators"]) - - -- (Removed) Indicators > Targeted Spells. The group-frame display it - -- configured is gone - Blizzard's 2026-04-07 UnitIsUnit hotfix removed the - -- only way to tell which group member an enemy was casting at. The page had - -- already been pulled from the sidebar; this removes the page itself, its - -- api-blocked overlay, and GUI.RefreshTargetedSpellsOverlay (no callers). - -- Personal Targeted and the Targeted List below are unaffected. - - -- ============================================================ - -- Indicators > Targeted List - -- ============================================================ - -- Stacked cast-bar display showing enemy casts targeting party - -- members. Replaces the group-frame Targeted Spells icons that - -- Blizzard's 2026-04-07 UnitIsUnit hotfix permanently broke. - -- Party-only feature; raid mode shows a redirect message. - local pageTargetedList = CreateSubTab("indicators", "indicators_targetedlist", L["Targeted List"]) - BuildPage(pageTargetedList, function(self, db, Add, AddSpace, AddSyncPoint) - -- Party-only feature: show message and return if in raid mode - if GUI.SelectedMode == "raid" then - Add(GUI:CreateHeader(self.child, L["Targeted List"]), 40, "both") - Add(GUI:CreateLabel(self.child, - L["Targeted List is a Party-only feature. Switch to Party mode to configure."], - 500, {r = 0.6, g = 0.6, b = 0.6}), 60, "both") - return - end - - -- Copy button at top - Add(CreateCopyButton(self.child, {"targetedList"}, L["Targeted List"], "indicators_targetedlist"), 25, 2) - - - - local growthOptions = { UP = L["Up"], DOWN = L["Down"] } - local iconPosOptions = { LEFT = L["Left"], RIGHT = L["Right"] } - local stylePresetOptions = { - DEFAULT = L["Default"], - COMPACT = L["Compact"], - DETAILED = L["Detailed"], - MINIMAL = L["Minimal"], - } - - - local function HideTLOptions(d) return not d.targetedListEnabled end - local function HideIconOptions(d) return not d.targetedListEnabled or not d.targetedListShowIcon end - local function HideTargetNameOptions(d) return not d.targetedListEnabled or not d.targetedListShowTargetName end - - local function TargetedListUpdate() - if DF.UpdateTargetedListLayout then DF:UpdateTargetedListLayout() end - end - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, - L["Shows a bar when an enemy is casting a spell targeting a party/raid member."], 250), 35) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, - "|cff888888" .. L["To reposition: Unlock frames (/df unlock) and drag the mover."] .. "|r", 250), 30) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable"], db, "targetedListEnabled", function() - self:RefreshStates() - if DF.ToggleTargetedList then DF:ToggleTargetedList(db.targetedListEnabled) end - -- Reflect the enable change in test mode immediately (so disabling - -- hides the test display, not just the live bars). - if DF.UpdateAllTestTargetedList then DF:UpdateAllTestTargetedList() end - end), 30) - local tlImportantOnly = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Important Spells Only"], db, "targetedListImportantOnly", TargetedListUpdate), 30) - tlImportantOnly.disableOn = HideTLOptions - local tlHideOwn = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Casts Targeting You"], db, "targetedListHideOwnCasts", TargetedListUpdate), 30) - tlHideOwn.disableOn = HideTLOptions - local tlShowUntargeted = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Untargeted Casts"], db, "targetedListShowUntargeted", TargetedListUpdate), 30) - tlShowUntargeted.disableOn = HideTLOptions - local tlHideOOC = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Out-of-Combat Casts"], db, "targetedListHideOutOfCombat", TargetedListUpdate), 30) - tlHideOOC.disableOn = HideTLOptions - tlHideOOC.tooltip = L["Hides the ambient spells idle NPCs cast while standing around: casts with no target, from an enemy that is not in combat. Casts aimed at you or a group member always show, so the opening cast of a pull is never hidden."] - -- Game CVar, not a profile key — bound straight to the CVar via - -- customGet/customSet so it cannot drift out of sync. See - -- DF:SetNameplateOffscreen for why both features depend on it. - local tlOffscreen = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Offscreen Nameplates"], nil, nil, nil, - function() return DF:GetNameplateOffscreen() end, - function(val) DF:SetNameplateOffscreen(val) end), 30) - tlOffscreen.disableOn = HideTLOptions - tlOffscreen.tooltip = L["Changes the Blizzard game setting 'nameplateShowOffscreen', which decides whether enemies outside your view still get a nameplate. This feature spots casts by watching the game's enemy nameplates, so with the setting off an enemy casting behind you is missed until you turn to face it — even if you have it targeted. Note that this is a game setting, not a DandersFrames one: it applies to your whole account and changes the game's nameplates everywhere."] - local tlMaxBars = settingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Bars"], 1, 20, 1, db, "targetedListMaxBars", TargetedListUpdate, TargetedListUpdate, true), 55) - tlMaxBars.disableOn = HideTLOptions - Add(settingsGroup, nil, 1) - - - - local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) - layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Size & Spacing"]), 40) - local tlW = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Bar Width"], 120, 600, 1, db, "targetedListWidth", TargetedListUpdate, TargetedListUpdate, true), 55) - tlW.disableOn = HideTLOptions - local tlH = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Bar Height"], 14, 48, 1, db, "targetedListHeight", TargetedListUpdate, TargetedListUpdate, true), 55) - tlH.disableOn = HideTLOptions - local tlSpace = layoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], 0, 10, 1, db, "targetedListSpacing", TargetedListUpdate, TargetedListUpdate, true), 55) - tlSpace.disableOn = HideTLOptions - local tlGrowth = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], growthOptions, db, "targetedListGrowth", TargetedListUpdate), 55) - tlGrowth.disableOn = HideTLOptions - local sortOptions = { NEWEST = L["Newest First"], OLDEST = L["Oldest First"], STATIC = L["Static (No Reorder)"] } - local tlSort = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Sort Order"], sortOptions, db, "targetedListSortOrder", TargetedListUpdate), 55) - tlSort.disableOn = HideTLOptions - Add(layoutGroup, nil, 1) - - local presetGroup = GUI:CreateSettingsGroup(self.child, 280) - presetGroup:AddWidget(GUI:CreateHeader(self.child, L["Bar Style"]), 40) - -- Picking a preset writes a bundle of settings to db - -- (bar dimensions, show/hide toggles, font size, etc.) - -- via DF:ApplyTargetedListPreset. After the bundle is - -- applied the individual settings remain editable — - -- the preset is a one-shot "start from this configuration" - -- action, not a continuous override. - local tlPreset = presetGroup:AddWidget(GUI:CreateDropdown(self.child, L["Bar Style"], stylePresetOptions, db, "targetedListStylePreset", function() - if DF.ApplyTargetedListPreset then - DF:ApplyTargetedListPreset(db.targetedListStylePreset) - end - -- Also refresh GUI widgets so users see the preset's - -- values reflected in the other sliders/checkboxes. - if GUI and GUI.RefreshCurrentPage then - GUI:RefreshCurrentPage() - end - TargetedListUpdate() - end), 55) - tlPreset.disableOn = HideTLOptions - local tlTexture = presetGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "targetedListTexture", TargetedListUpdate), 55) - tlTexture.disableOn = HideTLOptions - local tlBgAlpha = presetGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0, 1, 0.05, db, "targetedListBackgroundAlpha", TargetedListUpdate, TargetedListUpdate, true), 55) - tlBgAlpha.disableOn = HideTLOptions - Add(presetGroup, nil, 2) - - - - local colorGroup = GUI:CreateSettingsGroup(self.child, 280) - colorGroup:AddWidget(GUI:CreateHeader(self.child, L["Bar Color"]), 40) - local tlInterColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Interruptible Color"], db, "targetedListInterruptibleColor", true, TargetedListUpdate, function() if DF.LightweightUpdateTargetedListBarColor then DF:LightweightUpdateTargetedListBarColor() end end, true), 35) - tlInterColor.disableOn = HideTLOptions - local tlUninterColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Uninterruptible Color"], db, "targetedListUninterruptibleColor", true, TargetedListUpdate, function() if DF.LightweightUpdateTargetedListBarColor then DF:LightweightUpdateTargetedListBarColor() end end, true), 35) - tlUninterColor.disableOn = HideTLOptions - local tlSelfTargetEnabled = colorGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Self-Target Color"], db, "targetedListSelfTargetColorEnabled", function() - self:RefreshStates() - TargetedListUpdate() - end), 30) - tlSelfTargetEnabled.disableOn = HideTLOptions - tlSelfTargetEnabled.tooltip = L["Highlight the bar when the enemy is casting at you."] - local function HideSelfTargetOptions(d) return not d.targetedListEnabled or not d.targetedListSelfTargetColorEnabled end - local tlSelfTargetColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Self-Target Color"], db, "targetedListSelfTargetColor", true, TargetedListUpdate, nil, true), 35) - tlSelfTargetColor.disableOn = HideSelfTargetOptions - local tlHighlight = colorGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Highlight Important Spells"], db, "targetedListHighlightImportant", function() - self:RefreshStates() - TargetedListUpdate() - end), 30) - tlHighlight.disableOn = HideTLOptions - local function HideHighlightOptions(d) return not d.targetedListEnabled or not d.targetedListHighlightImportant end - local tlHighlightColor = colorGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Highlight Color"], db, "targetedListHighlightColor", true, TargetedListUpdate, function() if DF.LightweightUpdateTargetedListHighlightColor then DF:LightweightUpdateTargetedListHighlightColor() end end, true), 35) - tlHighlightColor.disableOn = HideHighlightOptions - local tlResetColors = colorGroup:AddWidget(GUI:CreateButton(self.child, L["Reset Colors to Default"], 200, 24, function() - db.targetedListInterruptibleColor = {r = 1, g = 0.494, b = 0.137, a = 1} - db.targetedListUninterruptibleColor = {r = 0.8, g = 0.302, b = 0.302, a = 1} - db.targetedListSelfTargetColor = {r = 0.02, g = 0.776, b = 0.4, a = 0.2} - db.targetedListHighlightColor = {r = 1, g = 0.8, b = 0, a = 1} - db.targetedListBorderColor = {r = 0.18, g = 0.18, b = 0.18, a = 1} - -- Refresh color swatches - if tlInterColor.UpdateSwatch then tlInterColor:UpdateSwatch() end - if tlUninterColor.UpdateSwatch then tlUninterColor:UpdateSwatch() end - if tlSelfTargetColor.UpdateSwatch then tlSelfTargetColor:UpdateSwatch() end - if tlHighlightColor.UpdateSwatch then tlHighlightColor:UpdateSwatch() end - TargetedListUpdate() - self:RefreshStates() - end), 30) - tlResetColors.disableOn = HideTLOptions - Add(colorGroup, nil, 2) - - -- Border gets its own box, after Appearance (Bar Style + Bar Color) - -- and before the element extras — the page-layout standard's column 2 - -- order. It used to be appended to the Bar Style box, where it read as - -- part of the style preset it has nothing to do with. - -- - -- Targeted List is a list view (N bars), so animate is deliberately - -- skipped (per-bar animation would be visual noise + a perf hit). - -- class/role colour skipped because the bars represent SPELLS, not - -- units. colour-by-time / colour-by-type also skipped (no aura state). - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - GUI:CreateBorderControls(borderGroup, db, "targetedList", { - parent = self.child, - include = { alpha = true, inset = true, blendMode = true, - gradient = true, shadow = true }, - fullUpdate = TargetedListUpdate, - lightUpdate = TargetedListUpdate, - lightColors = function() if DF.LightweightUpdateTargetedListBorderColor then DF:LightweightUpdateTargetedListBorderColor() end end, - refreshStates = function() self:RefreshStates() end, - sizeMin = 1, sizeMax = 6, sizeStep = 1, - -- GREY, not hide: the rest of this page greys via - -- disableOn = HideTLOptions, and the border block was the one - -- thing that vanished instead. - disableWhen = HideTLOptions, - }) - Add(borderGroup, nil, 2) - - local iconGroup = GUI:CreateSettingsGroup(self.child, 280) - iconGroup:AddWidget(GUI:CreateHeader(self.child, L["Icon"]), 40) - local tlShowIcon = iconGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Icon"], db, "targetedListShowIcon", function() - self:RefreshStates() - TargetedListUpdate() - end), 30) - tlShowIcon.disableOn = HideTLOptions - local tlIconPos = iconGroup:AddWidget(GUI:CreateDropdown(self.child, L["Icon Position"], iconPosOptions, db, "targetedListIconPosition", TargetedListUpdate), 55) - tlIconPos.disableOn = HideIconOptions - local tlZoom = iconGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Zoom Icon"], db, "targetedListZoomIcon", TargetedListUpdate), 30) - tlZoom.disableOn = HideIconOptions - Add(iconGroup, nil, 2) - - - - local textToggleGroup = GUI:CreateSettingsGroup(self.child, 280) - textToggleGroup:AddWidget(GUI:CreateHeader(self.child, L["Show Text"]), 40) - local tlShowSpellName = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Spell Name"], db, "targetedListShowSpellName", TargetedListUpdate), 30) - tlShowSpellName.disableOn = HideTLOptions - local tlShowTargetName = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Target Name"], db, "targetedListShowTargetName", function() - self:RefreshStates() - TargetedListUpdate() - end), 30) - tlShowTargetName.disableOn = HideTLOptions - local tlShowDuration = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "targetedListShowDuration", TargetedListUpdate), 30) - tlShowDuration.disableOn = HideTLOptions - local tlClassColor = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Target Name Class Color"], db, "targetedListTargetNameClassColor", TargetedListUpdate), 30) - tlClassColor.disableOn = HideTargetNameOptions - local tlArrow = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Arrow Prefix"], db, "targetedListShowArrowPrefix", TargetedListUpdate), 30) - tlArrow.disableOn = HideTargetNameOptions - local tlArrowSuffix = textToggleGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Arrow Suffix"], db, "targetedListShowArrowSuffix", TargetedListUpdate), 30) - tlArrowSuffix.disableOn = HideTargetNameOptions - Add(textToggleGroup, nil, 1) - - local fontGroup = GUI:CreateSettingsGroup(self.child, 280) - fontGroup:AddWidget(GUI:CreateHeader(self.child, L["Text Font"]), 40) - local tlFont = fontGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "targetedListFont", TargetedListUpdate), 55) - tlFont.disableOn = HideTLOptions - local tlFontSize = fontGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 8, 24, 1, db, "targetedListFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) - tlFontSize.disableOn = HideTLOptions - local tlFontOutline = fontGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "targetedListFontOutline", TargetedListUpdate), 55) - tlFontOutline.disableOn = HideTLOptions - local tlFontShadow = fontGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "targetedListFontOutline", TargetedListUpdate), 30) - tlFontShadow.disableOn = HideTLOptions - Add(fontGroup, nil, 2) - - - -- Per-element anchor + X/Y offset. Each text element - -- (spell name, target name, duration) can be independently - -- anchored to LEFT / CENTER / RIGHT within the bar's - -- progress region with a pixel offset applied on top. - - local textAnchorOptions = { LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } - local textAlignOptions = { LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } - - local spellNamePosGroup = GUI:CreateSettingsGroup(self.child, 280) - spellNamePosGroup:AddWidget(GUI:CreateHeader(self.child, L["Spell Name Position"]), 40) - local tlSNFontSize = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListSpellNameFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) - tlSNFontSize.disableOn = HideTLOptions - local tlSNWidth = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Text Width"], 0, 400, 1, db, "targetedListSpellNameWidth", TargetedListUpdate, TargetedListUpdate, true), 55) - tlSNWidth.disableOn = HideTLOptions - local tlSNAnchor = spellNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListSpellNameAnchor", TargetedListUpdate), 55) - tlSNAnchor.disableOn = HideTLOptions - local tlSNAlign = spellNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListSpellNameAlign", TargetedListUpdate), 55) - tlSNAlign.disableOn = HideTLOptions - local tlSNX = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListSpellNameX", TargetedListUpdate, TargetedListUpdate, true), 55) - tlSNX.disableOn = HideTLOptions - local tlSNY = spellNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListSpellNameY", TargetedListUpdate, TargetedListUpdate, true), 55) - tlSNY.disableOn = HideTLOptions - Add(spellNamePosGroup, nil, 1) - - local targetNamePosGroup = GUI:CreateSettingsGroup(self.child, 280) - targetNamePosGroup:AddWidget(GUI:CreateHeader(self.child, L["Target Name Position"]), 40) - local tlTNFontSize = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListTargetNameFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) - tlTNFontSize.disableOn = HideTargetNameOptions - local tlTNWidth = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Text Width"], 0, 400, 1, db, "targetedListTargetNameWidth", TargetedListUpdate, TargetedListUpdate, true), 55) - tlTNWidth.disableOn = HideTargetNameOptions - local tlTNAnchor = targetNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListTargetNameAnchor", TargetedListUpdate), 55) - tlTNAnchor.disableOn = HideTargetNameOptions - local tlTNAlign = targetNamePosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListTargetNameAlign", TargetedListUpdate), 55) - tlTNAlign.disableOn = HideTargetNameOptions - local tlTNX = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListTargetNameX", TargetedListUpdate, TargetedListUpdate, true), 55) - tlTNX.disableOn = HideTargetNameOptions - local tlTNY = targetNamePosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListTargetNameY", TargetedListUpdate, TargetedListUpdate, true), 55) - tlTNY.disableOn = HideTargetNameOptions - Add(targetNamePosGroup, nil, 2) - - local function HideDurationPosOptions(d) return not d.targetedListEnabled or not d.targetedListShowDuration end - local durationPosGroup = GUI:CreateSettingsGroup(self.child, 280) - durationPosGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Position"]), 40) - local tlDurFontSize = durationPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListDurationFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) - tlDurFontSize.disableOn = HideDurationPosOptions - local tlDurAnchor = durationPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListDurationAnchor", TargetedListUpdate), 55) - tlDurAnchor.disableOn = HideDurationPosOptions - local tlDurAlign = durationPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListDurationAlign", TargetedListUpdate), 55) - tlDurAlign.disableOn = HideDurationPosOptions - local tlDurX = durationPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListDurationX", TargetedListUpdate, TargetedListUpdate, true), 55) - tlDurX.disableOn = HideDurationPosOptions - local tlDurY = durationPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListDurationY", TargetedListUpdate, TargetedListUpdate, true), 55) - tlDurY.disableOn = HideDurationPosOptions - Add(durationPosGroup, nil, 1) - - local interruptPosGroup = GUI:CreateSettingsGroup(self.child, 280) - interruptPosGroup:AddWidget(GUI:CreateHeader(self.child, L["Interrupt Text Position"]), 40) - local tlIntFontSize = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 24, 1, db, "targetedListInterruptTextFontSize", TargetedListUpdate, TargetedListUpdate, true), 55) - tlIntFontSize.disableOn = HideTLOptions - local tlIntWidth = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Text Width"], 0, 400, 1, db, "targetedListInterruptTextWidth", TargetedListUpdate, TargetedListUpdate, true), 55) - tlIntWidth.disableOn = HideTLOptions - local tlIntAnchor = interruptPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], textAnchorOptions, db, "targetedListInterruptTextAnchor", TargetedListUpdate), 55) - tlIntAnchor.disableOn = HideTLOptions - local tlIntAlign = interruptPosGroup:AddWidget(GUI:CreateDropdown(self.child, L["Alignment"], textAlignOptions, db, "targetedListInterruptTextAlign", TargetedListUpdate), 55) - tlIntAlign.disableOn = HideTLOptions - local tlIntX = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "targetedListInterruptTextX", TargetedListUpdate, TargetedListUpdate, true), 55) - tlIntX.disableOn = HideTLOptions - local tlIntY = interruptPosGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "targetedListInterruptTextY", TargetedListUpdate, TargetedListUpdate, true), 55) - tlIntY.disableOn = HideTLOptions - Add(interruptPosGroup, nil, 2) - - - - local timingGroup = GUI:CreateSettingsGroup(self.child, 280) - timingGroup:AddWidget(GUI:CreateHeader(self.child, L["Timing"]), 40) - local tlFadeOut = timingGroup:AddWidget(GUI:CreateSlider(self.child, L["Fade Out Duration"], 0, 1, 0.05, db, "targetedListFadeOutDuration", TargetedListUpdate, TargetedListUpdate, true), 55) - tlFadeOut.disableOn = HideTLOptions - local tlFlashDur = timingGroup:AddWidget(GUI:CreateSlider(self.child, L["Interrupted Flash Duration"], 0, 2, 0.1, db, "targetedListInterruptedFlashDuration", TargetedListUpdate, TargetedListUpdate, true), 55) - tlFlashDur.disableOn = HideTLOptions - Add(timingGroup, nil, 1) - - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - -- DEPRECATED-TARGETED-SPELLS: link dropped with the sidebar row. - {pageId = "indicators_personal_targeted", label = L["Personal Targeted"]}, - }), 30, "both") - end) - - -- Indicators > Personal Targeted Spells (center of screen display for player) - local pagePersonalTargeted = CreateSubTab("indicators", "indicators_personal_targeted", L["Personal Targeted"]) - BuildPage(pagePersonalTargeted, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"personalTargeted"}, L["Personal Targeted"], "indicators_personal_targeted"), 25, 2) - - - - local growthOptions = { UP= L["Up"], DOWN= L["Down"], LEFT= L["Left"], RIGHT= L["Right"], CENTER_H= L["Center (Horizontal)"], CENTER_V= L["Center (Vertical)"] } - - local function HidePersonalOptions(d) return not d.personalTargetedSpellEnabled end - local function HidePersonalDurationOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellShowDuration end - - local function PersonalTargetedUpdate() - if DF.UpdatePersonalTargetedSpellsPosition then DF:UpdatePersonalTargetedSpellsPosition() end - if DF.UpdateTestPersonalTargetedSpells then DF:UpdateTestPersonalTargetedSpells() end - end - - -- ===== SETTINGS GROUP (Column 1) ===== - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Shows incoming targeted spells on YOU in the center of your screen."], 250), 30) - settingsGroup:AddWidget(GUI:CreateLabel(self.child, L["To reposition: Unlock frames (/df unlock) and drag the mover."], 250), 30) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Personal Targeted Spells"], db, "personalTargetedSpellEnabled", function() - self:RefreshStates() - if DF.TogglePersonalTargetedSpells then DF:TogglePersonalTargetedSpells(db.personalTargetedSpellEnabled) end - end), 30) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Important Spells Only"], db, "personalTargetedSpellImportantOnly", PersonalTargetedUpdate), 30) - -- Same game CVar as the Targeted List page — Personal detects casts through - -- nameplate tokens too (IsValidCasterUnit), so it has the identical - -- offscreen blind spot. Both checkboxes drive the one CVar. - local ptsOffscreen = settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Offscreen Nameplates"], nil, nil, nil, - function() return DF:GetNameplateOffscreen() end, - function(val) DF:SetNameplateOffscreen(val) end), 30) - ptsOffscreen.disableOn = HidePersonalOptions - ptsOffscreen.tooltip = L["Changes the Blizzard game setting 'nameplateShowOffscreen', which decides whether enemies outside your view still get a nameplate. This feature spots casts by watching the game's enemy nameplates, so with the setting off an enemy casting behind you is missed until you turn to face it — even if you have it targeted. Note that this is a game setting, not a DandersFrames one: it applies to your whole account and changes the game's nameplates everywhere."] - Add(settingsGroup, nil, 1) - - -- ===== CONTENT TYPES GROUP (Column 2) ===== - local contentGroup = GUI:CreateSettingsGroup(self.child, 280) - contentGroup:AddWidget(GUI:CreateHeader(self.child, L["Content Types"]), 40) - contentGroup:AddWidget(GUI:CreateLabel(self.child, L["Show in content types:"], 250), 25) - local ptsOpenWorld = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Open World"], db, "personalTargetedSpellInOpenWorld", nil), 25) - ptsOpenWorld.disableOn = HidePersonalOptions - ptsOpenWorld.hideOn = function() return GUI.SelectedMode == "raid" end - local ptsDungeons = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Dungeons"], db, "personalTargetedSpellInDungeons", nil), 25) - ptsDungeons.disableOn = HidePersonalOptions - ptsDungeons.hideOn = function() return GUI.SelectedMode == "raid" end - local ptsRaids = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Raids"], db, "personalTargetedSpellInRaids", nil), 25) - ptsRaids.disableOn = HidePersonalOptions - ptsRaids.hideOn = function() return GUI.SelectedMode == "raid" end - local ptsArena = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Arena"], db, "personalTargetedSpellInArena", nil), 25) - ptsArena.disableOn = HidePersonalOptions - ptsArena.hideOn = function() return GUI.SelectedMode == "raid" end - local ptsBattlegrounds = contentGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Battlegrounds"], db, "personalTargetedSpellInBattlegrounds", nil), 25) - ptsBattlegrounds.disableOn = HidePersonalOptions - ptsBattlegrounds.hideOn = function() return GUI.SelectedMode == "raid" end - contentGroup:AddWidget(GUI:CreateLabel(self.child, L["Content type filters configured in Party tab."], 250), 25) - -- No group-level hideOn: every checkbox in here already carries - -- disableOn = HidePersonalOptions, so the box greys in place like the - -- rest of the page instead of the whole column reflowing when the - -- feature is switched off. (The per-checkbox hideOn for RAID mode - -- stays — that one is about which mode you're in, not an off state.) - Add(contentGroup, nil, 2) - - - - -- Size Group (col1) - local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) - sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Size"]), 40) - local ptsSize = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 20, 80, 1, db, "personalTargetedSpellSize", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsSize.disableOn = HidePersonalOptions - local ptsScale = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "personalTargetedSpellScale", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsScale.disableOn = HidePersonalOptions - local ptsAlpha = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.0, 1.0, 0.05, db, "personalTargetedSpellAlpha", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsAlpha.disableOn = HidePersonalOptions - local ptsSpacing = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], 0, 20, 1, db, "personalTargetedSpellSpacing", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsSpacing.disableOn = HidePersonalOptions - local ptsMaxIcons = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Icons"], 1, 10, 1, db, "personalTargetedSpellMaxIcons", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsMaxIcons.disableOn = HidePersonalOptions - Add(sizeGroup, nil, 1) - - -- Growth Group (col2) - local growthGroup = GUI:CreateSettingsGroup(self.child, 280) - growthGroup:AddWidget(GUI:CreateHeader(self.child, L["Growth"]), 40) - local ptsGrowth = growthGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], growthOptions, db, "personalTargetedSpellGrowth", PersonalTargetedUpdate), 55) - ptsGrowth.disableOn = HidePersonalOptions - Add(growthGroup, nil, 1) - - - - -- Border Group (col1) — Stage 4.4: 3 hand-rolled border widgets - -- (Show / Size / Color) replaced by CreateBorderControls. include - -- set tailored for a "needs attention" alert surface (Personal - -- Targeted = spells targeting you). Skipped: offset (icon has its - -- own positioning), classColor / roleColor (spell alert, not unit - -- identity), colorByTime / colorByType (no aura-state context). - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - GUI:CreateBorderControls(borderGroup, db, "personalTargetedSpell", { - parent = self.child, - include = { alpha = true, inset = true, blendMode = true, - gradient = true, shadow = true, animate = true }, - fullUpdate = PersonalTargetedUpdate, - lightUpdate = PersonalTargetedUpdate, - lightColors = PersonalTargetedUpdate, - refreshStates = function() self:RefreshStates() end, - -- GREY, not hide — every other control on this page greys via - -- disableOn = HidePersonalOptions. - disableWhen = HidePersonalOptions, - sizeMin = 0, sizeMax = 5, sizeStep = 1, -- 0 = animation-only (no solid edge) - }) - Add(borderGroup, nil, 2) - - -- Duration Group (col2) - local durationGroup = GUI:CreateSettingsGroup(self.child, 280) - durationGroup:AddWidget(GUI:CreateHeader(self.child, L["Duration Text"]), 40) - local ptsDuration = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Duration"], db, "personalTargetedSpellShowDuration", function() - self:RefreshStates() - PersonalTargetedUpdate() - end), 30) - ptsDuration.disableOn = HidePersonalOptions - -- The cooldown swipe is the radial cooldown sweep on the icon (independent - -- of the numeric duration text), so it's gated only on the feature itself. - local ptsSwipe = durationGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Cooldown Swipe"], db, "personalTargetedSpellShowSwipe", PersonalTargetedUpdate), 30) - ptsSwipe.disableOn = HidePersonalOptions - local ptsDurFont = durationGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "personalTargetedSpellDurationFont", PersonalTargetedUpdate), 55) - ptsDurFont.disableOn = HidePersonalDurationOptions - local ptsDurScale = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.1, db, "personalTargetedSpellDurationScale", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsDurScale.disableOn = HidePersonalDurationOptions - local ptsDurOutline = durationGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "personalTargetedSpellDurationOutline", PersonalTargetedUpdate), 55) - ptsDurOutline.disableOn = HidePersonalDurationOptions - local ptsDurShadow = durationGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "personalTargetedSpellDurationOutline", PersonalTargetedUpdate), 30) - ptsDurShadow.disableOn = HidePersonalDurationOptions - local ptsDurX = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -20, 20, 1, db, "personalTargetedSpellDurationX", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsDurX.disableOn = HidePersonalDurationOptions - local ptsDurY = durationGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -20, 20, 1, db, "personalTargetedSpellDurationY", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsDurY.disableOn = HidePersonalDurationOptions - local ptsDurColor = durationGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "personalTargetedSpellDurationColor", false, PersonalTargetedUpdate), 35) - ptsDurColor.disableOn = HidePersonalDurationOptions - Add(durationGroup, nil, 2) - - - - local function HidePersonalHighlightOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellHighlightImportant end - - local highlightGroup = GUI:CreateSettingsGroup(self.child, 280) - highlightGroup:AddWidget(GUI:CreateHeader(self.child, L["Highlight Settings"]), 40) - local ptsHighlight = highlightGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Highlight Important Spells"], db, "personalTargetedSpellHighlightImportant", function() - self:RefreshStates() - PersonalTargetedUpdate() - end), 30) - ptsHighlight.disableOn = HidePersonalOptions - -- Important Spell Border: the highlight on its own DF.Border (full toolkit), - -- gated by the Highlight Important Spells toggle above. - GUI:CreateBorderControls(highlightGroup, db, "personalTargetedSpellImportant", { - parent = self.child, - noShowToggle = true, -- the Highlight Important Spells checkbox is the gate - include = { alpha = true, inset = true, blendMode = true, - gradient = true, shadow = true, animate = true }, - fullUpdate = PersonalTargetedUpdate, - lightUpdate = PersonalTargetedUpdate, - lightColors = PersonalTargetedUpdate, - refreshStates = function() self:RefreshStates() end, - disableWhen = HidePersonalHighlightOptions, - sizeMin = 0, sizeMax = 8, sizeStep = 1, - }) - Add(highlightGroup, nil, 1) - - - - local function HideInterruptOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellShowInterrupted end - local function HideInterruptXOptions(d) return not d.personalTargetedSpellEnabled or not d.personalTargetedSpellShowInterrupted or not d.personalTargetedSpellInterruptedShowX end - - local interruptGroup = GUI:CreateSettingsGroup(self.child, 280) - interruptGroup:AddWidget(GUI:CreateHeader(self.child, L["Interrupt Settings"]), 40) - local ptsInterrupted = interruptGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Interrupted Visual"], db, "personalTargetedSpellShowInterrupted", function() - self:RefreshStates() - PersonalTargetedUpdate() - end), 30) - ptsInterrupted.disableOn = HidePersonalOptions - local ptsInterruptDur = interruptGroup:AddWidget(GUI:CreateSlider(self.child, L["Duration"], 0.1, 2.0, 0.1, db, "personalTargetedSpellInterruptedDuration", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsInterruptDur.disableOn = HideInterruptOptions - local ptsInterruptTint = interruptGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Tint Color"], db, "personalTargetedSpellInterruptedTintColor", false, PersonalTargetedUpdate), 35) - ptsInterruptTint.disableOn = HideInterruptOptions - local ptsInterruptTintAlpha = interruptGroup:AddWidget(GUI:CreateSlider(self.child, L["Tint Opacity"], 0, 1, 0.1, db, "personalTargetedSpellInterruptedTintAlpha", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsInterruptTintAlpha.disableOn = HideInterruptOptions - Add(interruptGroup, nil, 2) - - local xMarkGroup = GUI:CreateSettingsGroup(self.child, 280) - xMarkGroup:AddWidget(GUI:CreateHeader(self.child, L["X Mark"]), 40) - local ptsShowX = xMarkGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show X Mark"], db, "personalTargetedSpellInterruptedShowX", function() - self:RefreshStates() - PersonalTargetedUpdate() - end), 30) - ptsShowX.disableOn = HideInterruptOptions - local ptsXColor = xMarkGroup:AddWidget(GUI:CreateColorPicker(self.child, L["X Color"], db, "personalTargetedSpellInterruptedXColor", false, PersonalTargetedUpdate), 35) - ptsXColor.disableOn = HideInterruptXOptions - local ptsXSize = xMarkGroup:AddWidget(GUI:CreateSlider(self.child, L["X Size"], 8, 40, 1, db, "personalTargetedSpellInterruptedXSize", PersonalTargetedUpdate, PersonalTargetedUpdate, true), 55) - ptsXSize.disableOn = HideInterruptXOptions - -- No group-level hideOn: the three controls already grey via their own - -- disableOn, so the box stays put when Show Interrupted Visual is off. - Add(xMarkGroup, nil, 2) - - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - -- DEPRECATED-TARGETED-SPELLS: this used to point at Targeted Spells, - -- which was the page's only link. Repointed rather than removed — - -- Targeted List is the surviving answer to the same question ("what - -- is being cast at my group"), and an empty See Also bar is worse - -- than no bar. - {pageId = "indicators_targetedlist", label = L["Targeted List"]}, - }), 30, "both") - end) - - -- Indicators > Icons - -- - -- ONE level of collapse on this page: the per-icon section header. Each - -- section then holds plain boxes (Settings / Appearance / Position, plus - -- Timer Text on AFK) that are always open. - -- - -- Those boxes used to be collapsible too, each with its own collapseKey. It - -- read as two levels of the same control -- expanding "Leader Icon" got you - -- three more things to expand before you could see a setting -- and made a - -- page of ordinary sliders feel deep. The section header is the only place - -- a collapse earns its keep here, because that IS the choice being made: - -- which icon am I configuring. Everything under it is one screen of rows. - -- - -- Section headers are 280 wide to match the boxes; they were 270, which - -- left the header bar visibly narrower than everything beneath it. - -- - -- Their slot is 36 -- the same as every other collapsible section in the - -- addon (28 of header + 8 of gap). It used to be 28, with the gap supplied - -- by a spacer frame REGISTERED AS A SECTION CHILD, so the gap collapsed - -- along with the section: correct while expanded, but this page defaults - -- every section to collapsed, and 13 headers with no gap between them ran - -- their borders together into one block. The gap belongs to the slot, not - -- to the contents. - local pageIcons = CreateSubTab("indicators", "indicators_icons", L["Icons"]) - BuildPage(pageIcons, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - -- Every icon on the page needs its own prefix; "combatIcon" was the one - -- omission, so the Combat icon's seven settings were skipped by Copy, Sync - -- and Reset while every other icon on the same page travelled. - Add(CreateCopyButton(self.child, {"roleIcon", "leaderIcon", "raidTargetIcon", "readyCheckIcon", "summonIcon", "resurrectionIcon", "phasedIcon", "afkIcon", "vehicleIcon", "raidRoleIcon", "bgCarrierIcon", "combatIcon", "statusIconFont", "statusIconFontSize", "statusIconFontOutline"}, L["Icons"], "indicators_icons"), 25, 2) - - local anchorOptions = { - CENTER = L["Center"], - TOP = L["Top"], - BOTTOM = L["Bottom"], - LEFT = L["Left"], - RIGHT = L["Right"], - TOPLEFT = L["Top Left"], - TOPRIGHT = L["Top Right"], - BOTTOMLEFT = L["Bottom Left"], - BOTTOMRIGHT = L["Bottom Right"], - } - - local roleStyleOptions = { - BLIZZARD = L["Blizzard"], - CUSTOM = "DF Icons", - EXTERNAL = L["External"], - } - - -- ============================================ - -- ICON TEXT SETTINGS (Collapsible, at top) - -- ============================================ - local textSection = Add(GUI:CreateCollapsibleSection(self.child, L["Icon Text Settings"], false, 280), 36, 1) - - local textLabel = Add(GUI:CreateLabel(self.child, L["Font settings for icons displayed as text (Summon, Res, AFK, etc.)"], 240), 30, 1) - textSection:RegisterChild(textLabel) - - local textFont = Add(GUI:CreateFontDropdown(self.child, L["Font"], db, "statusIconFont", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55, 1) - textSection:RegisterChild(textFont) - - local textSize = Add(GUI:CreateSlider(self.child, L["Font Size"], 8, 24, 1, db, "statusIconFontSize", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55, 1) - textSection:RegisterChild(textSize) - - local textOutline = Add(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "statusIconFontOutline", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55, 1) - textSection:RegisterChild(textOutline) - - local textShadow = Add(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "statusIconFontOutline", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30, 1) - textSection:RegisterChild(textShadow) - - local shadowNote = Add(GUI:CreateLabel(self.child, L["Shadow offset and colour are controlled in General > Global Fonts."], 240), 30, 1) - textSection:RegisterChild(shadowNote) - shadowNote.hideOn = function(d) return not DF:OutlineHasShadow(d.statusIconFontOutline) end - - -- Text Colors header - local colorsLabel = Add(GUI:CreateLabel(self.child, L["Text Colors:"], 240), 25, 1) - textSection:RegisterChild(colorsLabel) - - local summonColor = Add(GUI:CreateColorPicker(self.child, L["Summon"], db, "summonIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(summonColor) - - local resColor = Add(GUI:CreateColorPicker(self.child, L["Resurrection"], db, "resurrectionIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(resColor) - - local afkColor = Add(GUI:CreateColorPicker(self.child, L["AFK"], db, "afkIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(afkColor) - - local phasedColor = Add(GUI:CreateColorPicker(self.child, L["Phased"], db, "phasedIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(phasedColor) - - local vehicleColor = Add(GUI:CreateColorPicker(self.child, L["Vehicle"], db, "vehicleIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(vehicleColor) - - local raidRoleColor = Add(GUI:CreateColorPicker(self.child, L["Raid Role (MT/MA)"], db, "raidRoleIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(raidRoleColor) - - local bgCarrierColor = Add(GUI:CreateColorPicker(self.child, L["BG Carrier"], db, "bgCarrierIconTextColor", false, nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 30, 1) - textSection:RegisterChild(bgCarrierColor) - - -- ============================================ - -- ROLE ICON (Collapsible) - -- ============================================ - local roleSection = Add(GUI:CreateCollapsibleSection(self.child, L["Role Icon"], false, 280), 36, 1) - - -- Header preview: the Tank/Healer/DPS icons in the currently selected - -- style. Rebuilt live whenever the style, an external path, or a - -- per-role Show toggle changes. Each role's icon desaturates when its - -- Show toggle is off (matching the other icon sections' previews); - -- the whole preview dims only when all three roles are off. - local roleShowKeys = { TANK = "roleIconShowTank", HEALER = "roleIconShowHealer", DAMAGER = "roleIconShowDPS" } - local function UpdateRolePreview() - if not roleSection.SetPreviewIcons then return end - local icons = {} - local anyShown = false - for _, role in ipairs({ "TANK", "HEALER", "DAMAGER" }) do - -- tex may be an atlas name (no coords) or a texture path (+coords). - local tex, l, r, t, b = DF:GetRoleIconTexture(db, role) - if tex then - local shown = db[roleShowKeys[role]] ~= false - anyShown = anyShown or shown - icons[#icons + 1] = { texture = tex, coords = l and { l, r, t, b } or nil, desaturate = not shown } - end - end - roleSection:SetPreviewIcons(icons) - if roleSection.SetPreviewDimmed then roleSection:SetPreviewDimmed(not anyShown) end - end - - -- Settings - local roleSettings = GUI:CreateSettingsGroup(self.child, 280) - roleSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - roleSettings:AddWidget(GUI:CreateDropdown(self.child, L["Icon Style"], roleStyleOptions, db, "roleIconStyle", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 55) - local roleExtTank = roleSettings:AddWidget(GUI:CreateEditBox(self.child, L["Tank Icon Path"], db, "roleIconExternalTank", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end, nil, "Interface\\MyIcons\\Tank.tga"), 55) - roleExtTank.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end - local roleExtHealer = roleSettings:AddWidget(GUI:CreateEditBox(self.child, L["Healer Icon Path"], db, "roleIconExternalHealer", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end, nil, "Interface\\MyIcons\\Healer.tga"), 55) - roleExtHealer.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end - local roleExtDPS = roleSettings:AddWidget(GUI:CreateEditBox(self.child, L["DPS Icon Path"], db, "roleIconExternalDPS", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end, nil, "Interface\\MyIcons\\DPS.tga"), 55) - roleExtDPS.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end - local roleExtNote = roleSettings:AddWidget(GUI:CreateLabel(self.child, L["Paths are relative to your WoW folder and must start with Interface\\. Pasting a full path works — anything before 'Interface' is stripped. Leave empty for DF Icons."], 250), 70) - roleExtNote.hideOn = function(d) return d.roleIconStyle ~= "EXTERNAL" end - -- Per-role filters: which roles ever show an icon (global — apply in and - -- out of combat). The Hide In Combat toggle (Appearance) is an independent gate. - roleSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Tank"], db, "roleIconShowTank", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 30) - roleSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Healer"], db, "roleIconShowHealer", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 30) - roleSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show DPS"], db, "roleIconShowDPS", function() DF:UpdateAllRoleIcons(); UpdateRolePreview() end), 30) - Add(roleSettings, nil, 1) - roleSection:RegisterChild(roleSettings) - - -- Appearance - local roleAppearance = GUI:CreateSettingsGroup(self.child, 280) - roleAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - roleAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "roleIconScale", nil, function() DF:LightweightUpdateIconPosition("role") end, true), 55) - roleAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "roleIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("role") end, true), 55) - roleAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "roleIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("role") end, true)), 55) - roleAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide In Combat"], db, "roleIconHideInCombat", function() DF:UpdateAllRoleIcons() end), 30) - Add(roleAppearance, nil, 1) - roleSection:RegisterChild(roleAppearance) - - -- Position - local rolePosition = GUI:CreateSettingsGroup(self.child, 280) - rolePosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - rolePosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "roleIconAnchor", function() DF:LightweightUpdateIconPosition("role") end), 55) - rolePosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "roleIconX", nil, function() DF:LightweightUpdateIconPosition("role") end, true), 55) - rolePosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "roleIconY", nil, function() DF:LightweightUpdateIconPosition("role") end, true), 55) - Add(rolePosition, nil, 1) - roleSection:RegisterChild(rolePosition) - - -- Initial header preview for the current style. - UpdateRolePreview() - - -- ============================================ - -- STATUS-ICON HEADER PREVIEWS - -- Each status-icon section shows a representative swatch on its header - -- (or the configured status text when "Show as Text" is on), greyed out - -- when the section is disabled. Refreshers are registered globally and - -- re-run by hooked frame-update functions, so previews track live - -- enable/text changes without touching every control's callback. - -- ============================================ - if not DF._iconPreviewHooked then - DF._iconPreviewHooked = true - if DF.UpdateAllFrames then - hooksecurefunc(DF, "UpdateAllFrames", function() DF:RefreshIconPreviews() end) - end - if DF.UpdateAllFramesStatusIcons then - hooksecurefunc(DF, "UpdateAllFramesStatusIcons", function() DF:RefreshIconPreviews() end) - end - end - if DF.iconPreviewRefreshers then wipe(DF.iconPreviewRefreshers) end - - local function WireStatusPreview(section, opts) - local function refresh(force) - if not section.SetPreviewIcons then return end - if not force and not section:IsVisible() then return end - local enabled = (not opts.enableKey) or (db[opts.enableKey] ~= false) - -- Text entries use the icon's configured status-text colour so the - -- preview matches the frame (e.g. IconTextColor). - local colorKey = opts.enableKey and opts.enableKey:gsub("Enabled$", "TextColor") - local textColor = colorKey and db[colorKey] - local entries = {} - if opts.showTextKey and db[opts.showTextKey] then - for _, key in ipairs(opts.texts or {}) do - entries[#entries + 1] = { text = db[key] or key, color = textColor } - end - else - for _, ic in ipairs(opts.icons or {}) do - if type(ic) == "table" then - -- table form: { texture = , coords = {l,r,t,b} } - -- for icons that need a texcoord slice (e.g. raid-target markers - -- off the shared UI-RaidTargetingIcons sheet). SetIconTextureOrAtlas - -- auto-detects atlas vs path, so a plain texture string still works. - entries[#entries + 1] = { texture = ic.texture, coords = ic.coords, inset = ic.inset } - else - entries[#entries + 1] = { texture = ic } - end - end - end - for _, e in ipairs(entries) do e.desaturate = not enabled end - section:SetPreviewIcons(entries) - section:SetPreviewDimmed(not enabled) - end - if DF.iconPreviewRefreshers then table.insert(DF.iconPreviewRefreshers, refresh) end - refresh(true) - end - - -- ============================================ - -- LEADER ICON (Collapsible) - -- ============================================ - local leaderSection = Add(GUI:CreateCollapsibleSection(self.child, L["Leader Icon"], false, 280), 36, 1) - WireStatusPreview(leaderSection, { enableKey = "leaderIconEnabled", icons = { "Interface\\GroupFrame\\UI-Group-LeaderIcon" } }) - - -- Settings - local leaderSettings = GUI:CreateSettingsGroup(self.child, 280) - leaderSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - leaderSettings.disableChildrenOn = function(d) return not d.leaderIconEnabled end - local leaderIconEnableCb = leaderSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Leader Icon"], db, "leaderIconEnabled", function() DF:UpdateAllFrames() end), 30) - leaderIconEnableCb.keepEnabled = true - Add(leaderSettings, nil, 1) - leaderSection:RegisterChild(leaderSettings) - - -- Appearance - local leaderAppearance = GUI:CreateSettingsGroup(self.child, 280) - leaderAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - leaderAppearance.disableChildrenOn = function(d) return not d.leaderIconEnabled end - leaderAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "leaderIconScale", nil, function() DF:LightweightUpdateIconPosition("leader") end, true), 55) - leaderAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "leaderIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("leader") end, true), 55) - leaderAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "leaderIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("leader") end, true)), 55) - leaderAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "leaderIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(leaderAppearance, nil, 1) - leaderSection:RegisterChild(leaderAppearance) - - -- Position - local leaderPosition = GUI:CreateSettingsGroup(self.child, 280) - leaderPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - leaderPosition.disableChildrenOn = function(d) return not d.leaderIconEnabled end - leaderPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "leaderIconAnchor", function() DF:LightweightUpdateIconPosition("leader") end), 55) - leaderPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "leaderIconX", nil, function() DF:LightweightUpdateIconPosition("leader") end, true), 55) - leaderPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "leaderIconY", nil, function() DF:LightweightUpdateIconPosition("leader") end, true), 55) - Add(leaderPosition, nil, 1) - leaderSection:RegisterChild(leaderPosition) - - -- ============================================ - -- RAID TARGET ICON (Collapsible) - -- ============================================ - local raidTargetSection = Add(GUI:CreateCollapsibleSection(self.child, L["Target Marker Icon"], false, 280), 36, 1) - -- Header preview: the four most-used markers (square / cross / triangle / circle), - -- sliced from the classic raid-target sheet via texcoords (the atlas form won't render here). - WireStatusPreview(raidTargetSection, { enableKey = "raidTargetIconEnabled", icons = { - { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.25, 0.5, 0.25, 0.5 }, inset = 2 }, -- square (6) - { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.5, 0.75, 0.25, 0.5 }, inset = 2 }, -- cross (7) - { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.75, 1.0, 0.0, 0.25 }, inset = 2 }, -- triangle (4) - { texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons", coords = { 0.25, 0.5, 0.0, 0.25 }, inset = 2 }, -- circle (2) - } }) - - -- Settings - local rtSettings = GUI:CreateSettingsGroup(self.child, 280) - rtSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - rtSettings.disableChildrenOn = function(d) return not d.raidTargetIconEnabled end - local raidTargetIconEnableCb = rtSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Target Marker Icon"], db, "raidTargetIconEnabled", function() DF:UpdateAllFrames() end), 30) - raidTargetIconEnableCb.keepEnabled = true - Add(rtSettings, nil, 1) - raidTargetSection:RegisterChild(rtSettings) - - -- Appearance - local rtAppearance = GUI:CreateSettingsGroup(self.child, 280) - rtAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - rtAppearance.disableChildrenOn = function(d) return not d.raidTargetIconEnabled end - rtAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "raidTargetIconScale", nil, function() DF:LightweightUpdateIconPosition("raidTarget") end, true), 55) - rtAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "raidTargetIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("raidTarget") end, true), 55) - rtAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "raidTargetIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("raidTarget") end, true)), 55) - rtAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "raidTargetIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(rtAppearance, nil, 1) - raidTargetSection:RegisterChild(rtAppearance) - - -- Position - local rtPosition = GUI:CreateSettingsGroup(self.child, 280) - rtPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - rtPosition.disableChildrenOn = function(d) return not d.raidTargetIconEnabled end - rtPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "raidTargetIconAnchor", function() DF:LightweightUpdateIconPosition("raidTarget") end), 55) - rtPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "raidTargetIconX", nil, function() DF:LightweightUpdateIconPosition("raidTarget") end, true), 55) - rtPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "raidTargetIconY", nil, function() DF:LightweightUpdateIconPosition("raidTarget") end, true), 55) - Add(rtPosition, nil, 1) - raidTargetSection:RegisterChild(rtPosition) - - -- ============================================ - -- READY CHECK ICON (Collapsible) - -- ============================================ - local readySection = Add(GUI:CreateCollapsibleSection(self.child, L["Ready Check Icon"], false, 280), 36, 1) - WireStatusPreview(readySection, { enableKey = "readyCheckIconEnabled", icons = { "UI-LFG-ReadyMark-Raid" } }) - - -- Settings - local rcSettings = GUI:CreateSettingsGroup(self.child, 280) - rcSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - rcSettings.disableChildrenOn = function(d) return not d.readyCheckIconEnabled end - local readyCheckIconEnableCb = rcSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Ready Check Icon"], db, "readyCheckIconEnabled", function() DF:UpdateAllFrames() end), 30) - readyCheckIconEnableCb.keepEnabled = true - rcSettings:AddWidget(GUI:CreateSlider(self.child, L["Persist (seconds)"], 0, 15, 1, db, "readyCheckIconPersist"), 55) - Add(rcSettings, nil, 1) - readySection:RegisterChild(rcSettings) - - -- Appearance - local rcAppearance = GUI:CreateSettingsGroup(self.child, 280) - rcAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - rcAppearance.disableChildrenOn = function(d) return not d.readyCheckIconEnabled end - rcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "readyCheckIconScale", nil, function() DF:LightweightUpdateIconPosition("readyCheck") end, true), 55) - rcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "readyCheckIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("readyCheck") end, true), 55) - rcAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "readyCheckIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("readyCheck") end, true)), 55) - rcAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "readyCheckIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(rcAppearance, nil, 1) - readySection:RegisterChild(rcAppearance) - - -- Position - local rcPosition = GUI:CreateSettingsGroup(self.child, 280) - rcPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - rcPosition.disableChildrenOn = function(d) return not d.readyCheckIconEnabled end - rcPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "readyCheckIconAnchor", function() DF:LightweightUpdateIconPosition("readyCheck") end), 55) - rcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "readyCheckIconX", nil, function() DF:LightweightUpdateIconPosition("readyCheck") end, true), 55) - rcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "readyCheckIconY", nil, function() DF:LightweightUpdateIconPosition("readyCheck") end, true), 55) - Add(rcPosition, nil, 1) - readySection:RegisterChild(rcPosition) - - -- ============================================ - -- SUMMON ICON (Collapsible) - -- ============================================ - local summonSection = Add(GUI:CreateCollapsibleSection(self.child, L["Summon Icon"], false, 280), 36, 1) - WireStatusPreview(summonSection, { enableKey = "summonIconEnabled", showTextKey = "summonIconShowText", icons = { "RaidFrame-Icon-SummonPending" }, texts = { "summonIconTextPending" } }) - - -- Settings - local sumSettings = GUI:CreateSettingsGroup(self.child, 280) - sumSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - sumSettings.disableChildrenOn = function(d) return not d.summonIconEnabled end - local summonIconEnableCb = sumSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Summon Icon"], db, "summonIconEnabled", function() DF:UpdateAllFrames() end), 30) - summonIconEnableCb.keepEnabled = true - sumSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "summonIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - sumSettings:AddWidget(GUI:CreateEditBox(self.child, L["Pending Text"], db, "summonIconTextPending", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - sumSettings:AddWidget(GUI:CreateEditBox(self.child, L["Accepted Text"], db, "summonIconTextAccepted", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - sumSettings:AddWidget(GUI:CreateEditBox(self.child, L["Declined Text"], db, "summonIconTextDeclined", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - Add(sumSettings, nil, 1) - summonSection:RegisterChild(sumSettings) - - -- Appearance - local sumAppearance = GUI:CreateSettingsGroup(self.child, 280) - sumAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - sumAppearance.disableChildrenOn = function(d) return not d.summonIconEnabled end - sumAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "summonIconScale", nil, function() DF:LightweightUpdateIconPosition("summon") end, true), 55) - sumAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "summonIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("summon") end, true), 55) - sumAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "summonIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("summon") end, true)), 55) - sumAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "summonIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(sumAppearance, nil, 1) - summonSection:RegisterChild(sumAppearance) - - -- Position - local sumPosition = GUI:CreateSettingsGroup(self.child, 280) - sumPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - sumPosition.disableChildrenOn = function(d) return not d.summonIconEnabled end - sumPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "summonIconAnchor", function() DF:LightweightUpdateIconPosition("summon") end), 55) - sumPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "summonIconX", nil, function() DF:LightweightUpdateIconPosition("summon") end, true), 55) - sumPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "summonIconY", nil, function() DF:LightweightUpdateIconPosition("summon") end, true), 55) - Add(sumPosition, nil, 1) - summonSection:RegisterChild(sumPosition) - - -- ============================================ - -- BG OBJECTIVE CARRIER ICON (Collapsible) - -- Lights up a unit carrying a battleground objective - -- (flag / orb). Detection is UnitPvpClassification, so it - -- works with Blizzard raid frames fully disabled. - -- ============================================ - local bgCarrierSection = Add(GUI:CreateCollapsibleSection(self.child, L["BG Carrier Icon"], false, 280), 36, 1) - WireStatusPreview(bgCarrierSection, { enableKey = "bgCarrierIconEnabled", showTextKey = "bgCarrierIconShowText", icons = { "Interface\\Icons\\inv_bannerpvp_02" }, texts = { "bgCarrierIconText" } }) - - -- Settings - local bgcSettings = GUI:CreateSettingsGroup(self.child, 280) - bgcSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - bgcSettings.disableChildrenOn = function(d) return not d.bgCarrierIconEnabled end - local bgCarrierIconEnableCb = bgcSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable BG Carrier Icon"], db, "bgCarrierIconEnabled", function() DF:UpdateAllFrames() end), 30) - bgCarrierIconEnableCb.keepEnabled = true - bgcSettings:AddWidget(GUI:CreateLabel(self.child, L["Shows on a friendly party/raid member carrying a battleground objective (flag, orb). Only active inside battlegrounds."], 240), 44) - bgcSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "bgCarrierIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - bgcSettings:AddWidget(GUI:CreateEditBox(self.child, L["Carrier Text"], db, "bgCarrierIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - Add(bgcSettings, nil, 1) - bgCarrierSection:RegisterChild(bgcSettings) - - -- Appearance - local bgcAppearance = GUI:CreateSettingsGroup(self.child, 280) - bgcAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - bgcAppearance.disableChildrenOn = function(d) return not d.bgCarrierIconEnabled end - bgcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "bgCarrierIconScale", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - bgcAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "bgCarrierIconAlpha", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - bgcAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "bgCarrierIconFrameLevel", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true)), 55) - Add(bgcAppearance, nil, 1) - bgCarrierSection:RegisterChild(bgcAppearance) - - -- Position - local bgcPosition = GUI:CreateSettingsGroup(self.child, 280) - bgcPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - bgcPosition.disableChildrenOn = function(d) return not d.bgCarrierIconEnabled end - bgcPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "bgCarrierIconAnchor", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55) - bgcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "bgCarrierIconX", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - bgcPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "bgCarrierIconY", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - Add(bgcPosition, nil, 1) - bgCarrierSection:RegisterChild(bgcPosition) - - -- ============================================ - -- COMBAT ICON (Collapsible) - -- ============================================ - local combatSection = Add(GUI:CreateCollapsibleSection(self.child, L["Combat Icon"], false, 280), 36, 1) - -- Preview the swords quadrant of the UI-StateIcon sheet (texcoord slice); also - -- greys the section header when the icon is disabled. - WireStatusPreview(combatSection, { enableKey = "combatIconEnabled", icons = { { texture = "Interface\\CharacterFrame\\UI-StateIcon", coords = {0.5, 1.0, 0, 0.49} } } }) - - -- Settings - local combatSettings = GUI:CreateSettingsGroup(self.child, 280) - combatSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - combatSettings.disableChildrenOn = function(d) return not d.combatIconEnabled end - local combatIconEnableCb = combatSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Combat Icon"], db, "combatIconEnabled", function() DF:UpdateAllFrames() end), 30) - combatIconEnableCb.keepEnabled = true - combatSettings:AddWidget(GUI:CreateLabel(self.child, L["Shows crossed swords on a party/raid member who is in combat."], 240), 44) - Add(combatSettings, nil, 1) - combatSection:RegisterChild(combatSettings) - - -- Appearance - local combatAppearance = GUI:CreateSettingsGroup(self.child, 280) - combatAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - combatAppearance.disableChildrenOn = function(d) return not d.combatIconEnabled end - combatAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "combatIconScale", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - combatAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "combatIconAlpha", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - combatAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "combatIconFrameLevel", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true)), 55) - Add(combatAppearance, nil, 1) - combatSection:RegisterChild(combatAppearance) - - -- Position - local combatPosition = GUI:CreateSettingsGroup(self.child, 280) - combatPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - combatPosition.disableChildrenOn = function(d) return not d.combatIconEnabled end - combatPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "combatIconAnchor", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 55) - combatPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "combatIconX", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - combatPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "combatIconY", nil, function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end, true), 55) - Add(combatPosition, nil, 1) - combatSection:RegisterChild(combatPosition) - - -- ============================================ - -- RESURRECTION ICON (Collapsible) - -- ============================================ - local resSection = Add(GUI:CreateCollapsibleSection(self.child, L["Resurrection Icon"], false, 280), 36, 1) - WireStatusPreview(resSection, { enableKey = "resurrectionIconEnabled", showTextKey = "resurrectionIconShowText", icons = { "RaidFrame-Icon-Rez" }, texts = { "resurrectionIconTextCasting" } }) - - -- Settings - local resSettings = GUI:CreateSettingsGroup(self.child, 280) - resSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - resSettings.disableChildrenOn = function(d) return not d.resurrectionIconEnabled end - local resurrectionIconEnableCb = resSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Resurrection Icon"], db, "resurrectionIconEnabled", function() DF:UpdateAllFrames() end), 30) - resurrectionIconEnableCb.keepEnabled = true - resSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "resurrectionIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - resSettings:AddWidget(GUI:CreateEditBox(self.child, L["Casting Text"], db, "resurrectionIconTextCasting", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - -- ("Pending Text" removed: resurrectionIconTextPending was never read by - -- any render path — live or test — since inception. The pending state - -- renders as the yellow icon tint.) - Add(resSettings, nil, 1) - resSection:RegisterChild(resSettings) - - -- Appearance - local resAppearance = GUI:CreateSettingsGroup(self.child, 280) - resAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - resAppearance.disableChildrenOn = function(d) return not d.resurrectionIconEnabled end - resAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "resurrectionIconScale", nil, function() DF:LightweightUpdateIconPosition("resurrection") end, true), 55) - resAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "resurrectionIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("resurrection") end, true), 55) - resAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "resurrectionIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("resurrection") end, true)), 55) - Add(resAppearance, nil, 1) - resSection:RegisterChild(resAppearance) - - -- Position - local resPosition = GUI:CreateSettingsGroup(self.child, 280) - resPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - resPosition.disableChildrenOn = function(d) return not d.resurrectionIconEnabled end - resPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "resurrectionIconAnchor", function() DF:LightweightUpdateIconPosition("resurrection") end), 55) - resPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "resurrectionIconX", nil, function() DF:LightweightUpdateIconPosition("resurrection") end, true), 55) - resPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "resurrectionIconY", nil, function() DF:LightweightUpdateIconPosition("resurrection") end, true), 55) - Add(resPosition, nil, 1) - resSection:RegisterChild(resPosition) - - -- ============================================ - -- PHASED ICON (Collapsible) - -- ============================================ - local phasedSection = Add(GUI:CreateCollapsibleSection(self.child, L["Phased Icon"], false, 280), 36, 1) - WireStatusPreview(phasedSection, { enableKey = "phasedIconEnabled", showTextKey = "phasedIconShowText", icons = { "RaidFrame-Icon-Phasing" }, texts = { "phasedIconText" } }) - - -- Settings - local phSettings = GUI:CreateSettingsGroup(self.child, 280) - phSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - phSettings.disableChildrenOn = function(d) return not d.phasedIconEnabled end - local phasedIconEnableCb = phSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Phased Icon"], db, "phasedIconEnabled", function() DF:UpdateAllFrames() end), 30) - phasedIconEnableCb.keepEnabled = true - phSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "phasedIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - phSettings:AddWidget(GUI:CreateEditBox(self.child, L["Status Text"], db, "phasedIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - phSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show LFG Eye for Cross-Instance"], db, "phasedIconShowLFGEye", function() DF:UpdateAllFrames() end), 30) - Add(phSettings, nil, 1) - phasedSection:RegisterChild(phSettings) - - -- Appearance - local phAppearance = GUI:CreateSettingsGroup(self.child, 280) - phAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - phAppearance.disableChildrenOn = function(d) return not d.phasedIconEnabled end - phAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "phasedIconScale", nil, function() DF:LightweightUpdateIconPosition("phased") end, true), 55) - phAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "phasedIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("phased") end, true), 55) - phAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "phasedIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("phased") end, true)), 55) - phAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "phasedIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(phAppearance, nil, 1) - phasedSection:RegisterChild(phAppearance) - - -- Position - local phPosition = GUI:CreateSettingsGroup(self.child, 280) - phPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - phPosition.disableChildrenOn = function(d) return not d.phasedIconEnabled end - phPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "phasedIconAnchor", function() DF:LightweightUpdateIconPosition("phased") end), 55) - phPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "phasedIconX", nil, function() DF:LightweightUpdateIconPosition("phased") end, true), 55) - phPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "phasedIconY", nil, function() DF:LightweightUpdateIconPosition("phased") end, true), 55) - Add(phPosition, nil, 1) - phasedSection:RegisterChild(phPosition) - - -- ============================================ - -- AFK ICON (Collapsible) - -- ============================================ - local afkSection = Add(GUI:CreateCollapsibleSection(self.child, L["AFK Icon"], false, 280), 36, 1) - WireStatusPreview(afkSection, { enableKey = "afkIconEnabled", showTextKey = "afkIconShowText", icons = { "characterupdate_clock-icon" }, texts = { "afkIconText" } }) - - -- AFK is the one icon with a fourth box (Timer Text) on top of the - -- standard Settings / Appearance / Position trio. - local afkTimerCB = function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end - - -- Settings - local afkSettings = GUI:CreateSettingsGroup(self.child, 280) - afkSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - afkSettings.disableChildrenOn = function(d) return not d.afkIconEnabled end - local afkIconEnableCb = afkSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable AFK Icon"], db, "afkIconEnabled", function() DF:UpdateAllFrames() end), 30) - afkIconEnableCb.keepEnabled = true - afkSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "afkIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - afkSettings:AddWidget(GUI:CreateEditBox(self.child, L["Status Text"], db, "afkIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - afkSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Timer"], db, "afkIconShowTimer", function() DF:UpdateAllFramesStatusIcons() end), 30) - -- In text mode the timer is merged into the status text, so the Timer - -- Text box is hidden; explain it inherits the main text styling. - local afkTimerInheritNote = afkSettings:AddWidget(GUI:CreateLabel(self.child, L["In Text mode the timer joins the status text and uses its font, colour and position."], 230), 40) - afkTimerInheritNote.hideOn = function(d) return not d.afkIconShowText or not d.afkIconShowTimer end - Add(afkSettings, nil, 1) - afkSection:RegisterChild(afkSettings) - - -- Timer Text — elapsed-time text under the icon. Icon mode only (Show as - -- Text off) with Show Timer on, so the whole box is gated. - local afkTimerGroup = GUI:CreateSettingsGroup(self.child, 280) - afkTimerGroup:AddWidget(GUI:CreateHeader(self.child, L["Timer Text"]), GUI.RowHeight.sectionHeader) - afkTimerGroup.disableChildrenOn = function(d) return not d.afkIconEnabled end - afkTimerGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "afkIconTimerFont", afkTimerCB, "statusIconFont"), 55) - afkTimerGroup:AddWidget(GUI:CreateSlider(self.child, L["Size"], 6, 24, 1, db, "afkIconTimerFontSize", afkTimerCB, afkTimerCB, true), 55) - afkTimerGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "afkIconTimerOutline", afkTimerCB, "statusIconFontOutline"), 55) - afkTimerGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "afkIconTimerColor", false, nil, afkTimerCB, true), 30) - afkTimerGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "afkIconTimerX", afkTimerCB, afkTimerCB, true), 55) - afkTimerGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "afkIconTimerY", afkTimerCB, afkTimerCB, true), 55) - Add(afkTimerGroup, nil, 1) - afkSection:RegisterChild(afkTimerGroup) - afkTimerGroup.hideOn = function(d) return not d.afkIconShowTimer or d.afkIconShowText end - - -- Appearance - local afkAppearance = GUI:CreateSettingsGroup(self.child, 280) - afkAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - afkAppearance.disableChildrenOn = function(d) return not d.afkIconEnabled end - afkAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "afkIconScale", nil, function() DF:LightweightUpdateIconPosition("afk") end, true), 55) - afkAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "afkIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("afk") end, true), 55) - afkAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "afkIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("afk") end, true)), 55) - afkAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "afkIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(afkAppearance, nil, 1) - afkSection:RegisterChild(afkAppearance) - - -- Position - local afkPosition = GUI:CreateSettingsGroup(self.child, 280) - afkPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - afkPosition.disableChildrenOn = function(d) return not d.afkIconEnabled end - afkPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "afkIconAnchor", function() DF:LightweightUpdateIconPosition("afk") end), 55) - afkPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "afkIconX", nil, function() DF:LightweightUpdateIconPosition("afk") end, true), 55) - afkPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "afkIconY", nil, function() DF:LightweightUpdateIconPosition("afk") end, true), 55) - Add(afkPosition, nil, 1) - afkSection:RegisterChild(afkPosition) - - -- ============================================ - -- VEHICLE ICON (Collapsible) - -- ============================================ - local vehSection = Add(GUI:CreateCollapsibleSection(self.child, L["Vehicle Icon"], false, 280), 36, 1) - WireStatusPreview(vehSection, { enableKey = "vehicleIconEnabled", showTextKey = "vehicleIconShowText", icons = { "RaidFrame-Icon-Vehicle" }, texts = { "vehicleIconText" } }) - - -- Settings - local vehSettings = GUI:CreateSettingsGroup(self.child, 280) - vehSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - vehSettings.disableChildrenOn = function(d) return not d.vehicleIconEnabled end - local vehicleIconEnableCb = vehSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Vehicle Icon"], db, "vehicleIconEnabled", function() DF:UpdateAllFrames() end), 30) - vehicleIconEnableCb.keepEnabled = true - vehSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "vehicleIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - vehSettings:AddWidget(GUI:CreateEditBox(self.child, L["Status Text"], db, "vehicleIconText", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - Add(vehSettings, nil, 1) - vehSection:RegisterChild(vehSettings) - - -- Appearance - local vehAppearance = GUI:CreateSettingsGroup(self.child, 280) - vehAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - vehAppearance.disableChildrenOn = function(d) return not d.vehicleIconEnabled end - vehAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "vehicleIconScale", nil, function() DF:LightweightUpdateIconPosition("vehicle") end, true), 55) - vehAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "vehicleIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("vehicle") end, true), 55) - vehAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "vehicleIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("vehicle") end, true)), 55) - vehAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "vehicleIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(vehAppearance, nil, 1) - vehSection:RegisterChild(vehAppearance) - - -- Position - local vehPosition = GUI:CreateSettingsGroup(self.child, 280) - vehPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - vehPosition.disableChildrenOn = function(d) return not d.vehicleIconEnabled end - vehPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "vehicleIconAnchor", function() DF:LightweightUpdateIconPosition("vehicle") end), 55) - vehPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "vehicleIconX", nil, function() DF:LightweightUpdateIconPosition("vehicle") end, true), 55) - vehPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "vehicleIconY", nil, function() DF:LightweightUpdateIconPosition("vehicle") end, true), 55) - Add(vehPosition, nil, 1) - vehSection:RegisterChild(vehPosition) - - -- ============================================ - -- RAID ROLE ICON (Collapsible) - -- ============================================ - local rrSection = Add(GUI:CreateCollapsibleSection(self.child, L["Raid Role Icon (MT/MA)"], false, 280), 36, 1) - WireStatusPreview(rrSection, { enableKey = "raidRoleIconEnabled", showTextKey = "raidRoleIconShowText", icons = { "RaidFrame-Icon-MainTank", "RaidFrame-Icon-MainAssist" }, texts = { "raidRoleIconTextTank", "raidRoleIconTextAssist" } }) - - -- Settings - local rrSettings = GUI:CreateSettingsGroup(self.child, 280) - rrSettings:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), GUI.RowHeight.sectionHeader) - rrSettings.disableChildrenOn = function(d) return not d.raidRoleIconEnabled end - local raidRoleIconEnableCb = rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Raid Role Icon"], db, "raidRoleIconEnabled", function() DF:UpdateAllFrames() end), 30) - raidRoleIconEnableCb.keepEnabled = true - rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Main Tank"], db, "raidRoleIconShowTank", function() DF:UpdateAllFrames() end), 30) - rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show Main Assist"], db, "raidRoleIconShowAssist", function() DF:UpdateAllFrames() end), 30) - rrSettings:AddWidget(GUI:CreateCheckbox(self.child, L["Show as Text"], db, "raidRoleIconShowText", function() DF:UpdateAllFramesStatusIcons(); DF:RefreshTestFrames() end), 30) - rrSettings:AddWidget(GUI:CreateEditBox(self.child, L["Tank Text"], db, "raidRoleIconTextTank", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - rrSettings:AddWidget(GUI:CreateEditBox(self.child, L["Assist Text"], db, "raidRoleIconTextAssist", function() DF:UpdateAllFramesStatusIcons() end, 120), 55) - Add(rrSettings, nil, 1) - rrSection:RegisterChild(rrSettings) - - -- Appearance - local rrAppearance = GUI:CreateSettingsGroup(self.child, 280) - rrAppearance:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), GUI.RowHeight.sectionHeader) - rrAppearance.disableChildrenOn = function(d) return not d.raidRoleIconEnabled end - rrAppearance:AddWidget(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.5, 0.1, db, "raidRoleIconScale", nil, function() DF:LightweightUpdateIconPosition("raidRole") end, true), 55) - rrAppearance:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "raidRoleIconAlpha", nil, function() DF:LightweightUpdateIconAlpha("raidRole") end, true), 55) - rrAppearance:AddWidget(GUI:SetFrameLevelTooltip(GUI:CreateSlider(self.child, L["Frame Level"], 0, 100, 1, db, "raidRoleIconFrameLevel", nil, function() DF:LightweightUpdateFrameLevel("raidRole") end, true)), 55) - rrAppearance:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "raidRoleIconHideInCombat", function() DF:UpdateAllFrames() end), 30) - Add(rrAppearance, nil, 1) - rrSection:RegisterChild(rrAppearance) - - -- Position - local rrPosition = GUI:CreateSettingsGroup(self.child, 280) - rrPosition:AddWidget(GUI:CreateHeader(self.child, L["Position"]), GUI.RowHeight.sectionHeader) - rrPosition.disableChildrenOn = function(d) return not d.raidRoleIconEnabled end - rrPosition:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorOptions, db, "raidRoleIconAnchor", function() DF:LightweightUpdateIconPosition("raidRole") end), 55) - rrPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "raidRoleIconX", nil, function() DF:LightweightUpdateIconPosition("raidRole") end, true), 55) - rrPosition:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "raidRoleIconY", nil, function() DF:LightweightUpdateIconPosition("raidRole") end, true), 55) - Add(rrPosition, nil, 1) - rrSection:RegisterChild(rrPosition) - end) - - -- Indicators > Highlights - local pageHighlights = CreateSubTab("indicators", "indicators_highlights", L["Highlights"]) - BuildPage(pageHighlights, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"selectionHighlight", "hoverHighlight", "aggroHighlight", "aggro"}, L["Highlights"], "indicators_highlights"), 25, 2) - - - local currentSection = nil - - local function AddToSection(widget, height, col) - Add(widget, height, col) - if currentSection then currentSection:RegisterChild(widget) end - return widget - end - - local highlightModes = { - ["NONE"] = L["Hidden"], - ["SOLID"] = L["Solid Border"], - ["ANIMATED"] = L["Animated Border"], - ["DASHED"] = L["Dashed Border"], - ["GLOW"] = L["Glow"], - ["CORNERS"] = L["Corners Only"], - } - - -- All three highlights are the same Thickness / Inset / Alpha trio, so the - -- Inset explanation is written once. Thickness and Alpha get nothing — - -- they say what they are; Inset is the one that reads as jargon, and here - -- the label is a bare "Inset" with not even "Border" in front of it. - local TIP_HL_INSET = L["How far inside the frame edge the highlight sits. Negative values push it outward, so it rings the frame instead of hugging it — useful when the highlight would otherwise sit under auras or text."] - - -- ======================================== - -- SELECTION HIGHLIGHT SECTION - -- ======================================== - local selectionSection = Add(GUI:CreateCollapsibleSection(self.child, L["Selection Highlight"], true), 36, "both") - currentSection = selectionSection - - local function HideSelectionOptions(d) return d.selectionHighlightMode == "NONE" end - - local selGroup = GUI:CreateSettingsGroup(self.child, 280) - selGroup:AddWidget(GUI:CreateHeader(self.child, L["Selection Settings"]), 40) - selGroup:AddWidget(GUI:CreateDropdown(self.child, L["Mode"], highlightModes, db, "selectionHighlightMode", function() - self:RefreshStates() - end), 55) - local selThick = selGroup:AddWidget(GUI:CreateSlider(self.child, L["Thickness"], 1, 10, 1, db, "selectionHighlightThickness", nil, function() DF:LightweightUpdateHighlight("selection") end, true), 55) - selThick.hideOn = HideSelectionOptions - local selInset = selGroup:AddWidget(GUI:CreateSlider(self.child, L["Inset"], -10, 10, 1, db, "selectionHighlightInset", nil, function() DF:LightweightUpdateHighlight("selection") end, true), 55) - selInset.hideOn = HideSelectionOptions - selInset.tooltip = TIP_HL_INSET - local selAlpha = selGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "selectionHighlightAlpha", nil, function() DF:LightweightUpdateHighlight("selection") end, true), 55) - selAlpha.hideOn = HideSelectionOptions - local selCol = selGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "selectionHighlightColor", false, nil, function() DF:LightweightUpdateSelectionHighlightColor() end, true), 35) - selCol.hideOn = HideSelectionOptions - AddToSection(selGroup, nil, 1) - - currentSection = nil - AddSpace(GUI.Space.section, "both") - - -- ======================================== - -- HOVER HIGHLIGHT SECTION - -- ======================================== - local hoverSection = Add(GUI:CreateCollapsibleSection(self.child, L["Hover Highlight"], true), 36, "both") - currentSection = hoverSection - - local function HideHoverOptions(d) return d.hoverHighlightMode == "NONE" end - - local hoverGroup = GUI:CreateSettingsGroup(self.child, 280) - hoverGroup:AddWidget(GUI:CreateHeader(self.child, L["Hover Settings"]), 40) - hoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Mode"], highlightModes, db, "hoverHighlightMode", function() - self:RefreshStates() - end), 55) - local hoverThick = hoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Thickness"], 1, 10, 1, db, "hoverHighlightThickness", nil, function() DF:LightweightUpdateHighlight("hover") end, true), 55) - hoverThick.hideOn = HideHoverOptions - local hoverInset = hoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Inset"], -10, 10, 1, db, "hoverHighlightInset", nil, function() DF:LightweightUpdateHighlight("hover") end, true), 55) - hoverInset.hideOn = HideHoverOptions - hoverInset.tooltip = TIP_HL_INSET - local hoverAlpha = hoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "hoverHighlightAlpha", nil, function() DF:LightweightUpdateHighlight("hover") end, true), 55) - hoverAlpha.hideOn = HideHoverOptions - local hoverCol = hoverGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Color"], db, "hoverHighlightColor", false, nil, function() DF:LightweightUpdateHighlight("hover") end, true), 35) - hoverCol.hideOn = HideHoverOptions - AddToSection(hoverGroup, nil, 1) - - currentSection = nil - AddSpace(GUI.Space.section, "both") - - -- ======================================== - -- AGGRO HIGHLIGHT SECTION - -- ======================================== - local aggroSection = Add(GUI:CreateCollapsibleSection(self.child, L["Aggro Highlight"], true), 36, "both") - currentSection = aggroSection - - local function HideAggroOptions(d) return d.aggroHighlightMode == "NONE" or d.aggroHighlightMode == "HEALTH_COLOR" end - local function HideAggroModeNone(d) return d.aggroHighlightMode == "NONE" end - local function HideCustomColorOptions(d) return d.aggroHighlightMode == "NONE" or not d.aggroUseCustomColors end - local function HideNonTankingColors(d) return d.aggroHighlightMode == "NONE" or not d.aggroUseCustomColors or d.aggroOnlyTanking end - - local aggroModes = { - ["NONE"] = L["Hidden"], - ["HEALTH_COLOR"] = L["Health Bar Color"], - ["SOLID"] = L["Solid Border"], - ["ANIMATED"] = L["Animated Border"], - ["DASHED"] = L["Dashed Border"], - ["GLOW"] = L["Glow"], - ["CORNERS"] = L["Corners Only"], - } - - -- Aggro Settings Group (col1) - local aggroGroup = GUI:CreateSettingsGroup(self.child, 280) - aggroGroup:AddWidget(GUI:CreateHeader(self.child, L["Aggro Settings"]), 40) - aggroGroup:AddWidget(GUI:CreateDropdown(self.child, L["Mode"], aggroModes, db, "aggroHighlightMode", function() - self:RefreshStates() - if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end - end), 55) - local aggroOnlyTanking = aggroGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Only Show When Tanking"], db, "aggroOnlyTanking", function() - self:RefreshStates() - if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end - end), 28) - aggroOnlyTanking.hideOn = HideAggroModeNone - -- These two sound like the same thing and are not: one is about YOUR - -- role, the other about the unit's. Both say which, from their side. - aggroOnlyTanking.tooltip = L["Only highlight threat while YOU are tanking. As a healer or damage dealer the highlight stays off entirely."] - local aggroHideOnTanks = aggroGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide on Tanks"], db, "aggroHideOnTanks", function() - if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end - end), 28) - aggroHideOnTanks.hideOn = HideAggroModeNone - aggroHideOnTanks.tooltip = L["Skip the highlight on tanks in your group — they are supposed to have threat, so lighting them up is noise. Everyone else still shows."] - local aggroThick = aggroGroup:AddWidget(GUI:CreateSlider(self.child, L["Thickness"], 1, 10, 1, db, "aggroHighlightThickness", nil, function() DF:LightweightUpdateHighlight("aggro") end, true), 55) - aggroThick.hideOn = HideAggroOptions - local aggroInset = aggroGroup:AddWidget(GUI:CreateSlider(self.child, L["Inset"], -10, 10, 1, db, "aggroHighlightInset", nil, function() DF:LightweightUpdateHighlight("aggro") end, true), 55) - aggroInset.hideOn = HideAggroOptions - aggroInset.tooltip = TIP_HL_INSET - local aggroAlpha = aggroGroup:AddWidget(GUI:CreateSlider(self.child, L["Alpha"], 0.1, 1.0, 0.05, db, "aggroHighlightAlpha", nil, function() DF:LightweightUpdateHighlight("aggro") end, true), 55) - aggroAlpha.hideOn = HideAggroOptions - AddToSection(aggroGroup, nil, 1) - - -- Threat Colors Group (col2) - local threatGroup = GUI:CreateSettingsGroup(self.child, 280) - threatGroup:AddWidget(GUI:CreateHeader(self.child, L["Threat Colors"]), 40) - local useCustomColors = threatGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Custom Colors"], db, "aggroUseCustomColors", function() - self:RefreshStates() - if DF.UpdateAllHighlights then DF:UpdateAllHighlights() end - end), 28) - useCustomColors.hideOn = HideAggroModeNone - local colorHighThreat = threatGroup:AddWidget(GUI:CreateColorPicker(self.child, L["High Threat (Yellow)"], db, "aggroColorHighThreat", false, nil, function() - DF:LightweightUpdateHighlight("aggro") - end, true), 30) - colorHighThreat.hideOn = HideNonTankingColors - local colorHighestThreat = threatGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Highest Threat (Orange)"], db, "aggroColorHighestThreat", false, nil, function() - DF:LightweightUpdateHighlight("aggro") - end, true), 30) - colorHighestThreat.hideOn = HideNonTankingColors - local colorTanking = threatGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Tanking (Red)"], db, "aggroColorTanking", false, nil, function() - DF:LightweightUpdateHighlight("aggro") - end, true), 30) - colorTanking.hideOn = HideCustomColorOptions - threatGroup:AddWidget(GUI:CreateLabel(self.child, L["Yellow=high, Orange=highest, Red=tanking."], 230), 25) - threatGroup.hideOn = HideAggroModeNone - AddToSection(threatGroup, nil, 2) - - currentSection = nil - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_dispel", label = L["Dispel Overlay"]}, - }), 30, "both") - end) - - -- Auras > Dispel Overlay (moved from Indicators) - local pageDispel = CreateSubTab("auras", "auras_dispel", L["Dispel Overlay"]) - BuildPage(pageDispel, function(self, db, Add, AddSpace, AddSyncPoint) - -- Copy button at top - Add(CreateCopyButton(self.child, {"dispel"}, L["Dispel Overlay"], "auras_dispel"), 25, 2) - - - local function HideIfDisabled(d) - return d.dispelOverlayEnabled == false - end - -- Alias kept so the widget wiring below reads unchanged — under the - -- unified overlay every appearance control simply follows the toggle. - local HideDispelOptions = HideIfDisabled - - -- 12.1: the container factory owns the overlay unconditionally - -- (FactoryOwnsDispelOverlay == AuraContainer.IsSupported()), so the - -- Display/Icon/Border/Gradient groups are always live here. The legacy - -- "frost while the old path owns it" guards were unreachable and are gone. - - -- Every dispel-page callback funnels through here: the version bump - -- breaks the 12.1 factory drive's fast-path latch, so structural changes - -- (colour source, me/all, icon slots, bleed opt-in) rebuild their slot - -- set and pure styling re-applies. Cheap out of combat; no-op impact - -- pre-12.1 (the legacy path reads settings directly). - local function ApplyDispelSettings() - if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end - if DF.UpdateAllDispelOverlays then DF:UpdateAllDispelOverlays() end - end - - local function InvalidateCurves() - if DF.InvalidateDispelColorCurve then DF:InvalidateDispelColorCurve() end - ApplyDispelSettings() - end - - local function OnDispelTypeChanged() - InvalidateCurves() - end - - -- ===== ENABLE + SHARED SETTINGS ===== - -- 12.1 unified overlay: ONE container-slot-driven system (Features/ - -- Dispel.lua factory path) covering normal AND private-aura dispels - -- natively. The old Off / DandersFrames / Blizzard / Hybrid source - -- selector collapsed into this single toggle when the Blizzard wrapper - -- retired (settings migrate: any non-Off source = enabled). - local settingsGroup = GUI:CreateSettingsGroup(self.child, 280) - settingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings"]), 40) - settingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Dispel Overlay"], db, "dispelOverlayEnabled", function() - ApplyDispelSettings() - self:RefreshStates() - GUI:RefreshCurrentPage() - end), 30) - local dispelIndicatorOptions = { [1]= L["Dispellable By Me"], [2]= L["All Dispellable"] } - local dispelIndicatorDropdown = settingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Show Overlay For"], dispelIndicatorOptions, db, "dispelOverlayDispelType", function() - OnDispelTypeChanged() - end), 55) - dispelIndicatorDropdown.hideOn = HideIfDisabled - dispelIndicatorDropdown.tooltip = L["Dispellable By Me only lights up debuffs your current spec can actually remove. All Dispellable lights up every removable debuff, including ones for someone else to handle."] - -- Dispel-type colours come from the shared account palette on the Colors page - -- (defaults = the game palette; Reset restores it). The overlay always follows - -- it — no game-vs-custom toggle — so this is just a link to where you edit them. - local overlayColorsLink = GUI:CreateDispelColorsPageLink(self.child, 260) - settingsGroup:AddWidget(overlayColorsLink, (overlayColorsLink.layoutHeight or 16) + 2) - overlayColorsLink.hideOn = HideIfDisabled - Add(settingsGroup, nil, 1) - - -- The four boxes below used to sit under an "Appearance" collapsible - -- header -- the last section in the addon named for a CATEGORY rather - -- than for a thing. A header means "here is another one of these", - -- which is why Icons and Highlights keep theirs and this one goes. - -- - -- It costs nothing to remove: every box already declares the same - -- hideOn it was inheriting from the section, so the whole block still - -- disappears when the overlay is off. - - -- Display group (quick toggles) — Column 1 - local displayGroup = GUI:CreateSettingsGroup(self.child, 280) - displayGroup:AddWidget(GUI:CreateHeader(self.child, L["Display"]), 40) - -- Show Border / Show Gradient are the master toggles for their features, so - -- each one now HEADS its own group below (Border / Gradient) — mirroring the - -- Show Dispel Icon toggle that heads the Icon group. Keeps every group's - -- on/off switch at the top of that group. - -- Boolean toggles GREY their dependent controls in place (addon-wide - -- convention); hideOn stays for the feature/variant switches only. - local DisableIfNoGradient = function(d) return d.dispelShowGradient == false end - local DisableIfNoBorder = function(d) return d.dispelShowBorder == false end - local DisableIfNoIcon = function(d) return d.dispelShowIcon == false end - local animate = displayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Pulse Overlay"], db, "dispelAnimate", function() - ApplyDispelSettings() - end), 30) - animate.hideOn = HideDispelOptions - -- (Color Name Text removed 2026-07-25 — see Features/Dispel.lua. Its only render - -- path was the legacy test-mode show, so it tinted the preview and did nothing - -- live; a real version needs an occlusion-safe name tint on the slot overlay.) - displayGroup.hideOn = HideDispelOptions - Add(displayGroup, nil, 1) - - -- ===== ICON GROUP (Column 2) ===== - local iconGroup = GUI:CreateSettingsGroup(self.child, 280) - iconGroup:AddWidget(GUI:CreateHeader(self.child, L["Dispel Type Icon"]), 40) - local showIcon = iconGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Dispel Icon"], db, "dispelShowIcon", function() - ApplyDispelSettings() - self:RefreshStates() - end), 30) - showIcon.hideOn = HideDispelOptions - local iconSize = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Size"], 10, 40, 1, db, "dispelIconSize", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - iconSize.hideOn = HideDispelOptions - iconSize.disableOn = DisableIfNoIcon - local iconAlpha = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Icon Opacity"], 0.1, 1.0, 0.1, db, "dispelIconAlpha", function() - InvalidateCurves() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - iconAlpha.hideOn = HideDispelOptions - iconAlpha.disableOn = DisableIfNoIcon - local iconPositions = { - ["CENTER"]= L["Center"], ["TOP"]= L["Top"], ["BOTTOM"]= L["Bottom"], - ["LEFT"]= L["Left"], ["RIGHT"]= L["Right"], - ["TOPLEFT"]= L["Top Left"], ["TOPRIGHT"]= L["Top Right"], - ["BOTTOMLEFT"]= L["Bottom Left"], ["BOTTOMRIGHT"]= L["Bottom Right"], - } - local iconPos = iconGroup:AddWidget(GUI:CreateDropdown(self.child, L["Icon Position"], iconPositions, db, "dispelIconPosition", function() - ApplyDispelSettings() - end), 55) - iconPos.hideOn = HideDispelOptions - iconPos.disableOn = DisableIfNoIcon - local iconOffsetX = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "dispelIconOffsetX", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - iconOffsetX.hideOn = HideDispelOptions - iconOffsetX.disableOn = DisableIfNoIcon - local iconOffsetY = iconGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "dispelIconOffsetY", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - iconOffsetY.hideOn = HideDispelOptions - iconOffsetY.disableOn = DisableIfNoIcon - iconGroup.hideOn = HideDispelOptions - Add(iconGroup, nil, 2) - - -- ===== BORDER GROUP (Column 1) ===== - local borderGroup = GUI:CreateSettingsGroup(self.child, 280) - borderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) - local showBorder = borderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Border"], db, "dispelShowBorder", function() - ApplyDispelSettings() - self:RefreshStates() - end), 30) - showBorder.hideOn = HideDispelOptions - local borderSize = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Thickness"], 1, 6, 1, db, "dispelBorderSize", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - borderSize.hideOn = HideDispelOptions - borderSize.disableOn = DisableIfNoBorder - local borderInset = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Inset"], -4, 4, 1, db, "dispelBorderInset", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - borderInset.hideOn = HideDispelOptions - borderInset.disableOn = DisableIfNoBorder - borderInset.tooltip = L["How far inside the frame edge the dispel border sits. Negative values push it outward, ringing the frame rather than hugging it."] - local borderAlpha = borderGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Opacity"], 0.1, 1.0, 0.1, db, "dispelBorderAlpha", function() - InvalidateCurves() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - borderAlpha.hideOn = HideDispelOptions - borderAlpha.disableOn = DisableIfNoBorder - borderGroup.hideOn = HideDispelOptions -- works in BOTH modes (game = ring slot) - Add(borderGroup, nil, 2) - - -- ===== GRADIENT GROUP (Column 1) ===== - -- Column 1 with Display, not column 2 with Border: this is the OVERLAY's - -- own gradient (Full Frame / Top Edge / Edge Glow), so it belongs with - -- the overlay's display mode rather than with the border drawn over it. - local gradientGroup = GUI:CreateSettingsGroup(self.child, 280) - gradientGroup:AddWidget(GUI:CreateHeader(self.child, L["Gradient"]), 40) - local showGradient = gradientGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Gradient"], db, "dispelShowGradient", function() - ApplyDispelSettings() - self:RefreshStates() - end), 30) - showGradient.hideOn = HideDispelOptions - local gradientStyles = { - ["FULL"]= L["Full Frame"], ["TOP"]= L["Top Edge"], ["BOTTOM"]= L["Bottom Edge"], - ["LEFT"]= L["Left Edge"], ["RIGHT"]= L["Right Edge"], ["EDGE"]= L["Edge Glow (All Sides)"], - } - local gradStyle = gradientGroup:AddWidget(GUI:CreateDropdown(self.child, L["Gradient Position"], gradientStyles, db, "dispelGradientStyle", function() - self:RefreshStates() - ApplyDispelSettings() - end), 55) - gradStyle.hideOn = HideDispelOptions - gradStyle.disableOn = DisableIfNoGradient - gradStyle.tooltip = L["Where the coloured wash sits on the frame. Full covers the whole bar; the edge options leave the middle clear so you can still read health and text underneath."] - local onHealthCheck = gradientGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show On Current Health Only"], db, "dispelGradientOnCurrentHealth", function() - ApplyDispelSettings() - end), 30) - onHealthCheck.hideOn = function(d) return HideIfDisabled(d) or d.dispelGradientStyle ~= "FULL" end - onHealthCheck.disableOn = DisableIfNoGradient - onHealthCheck.tooltip = L["Keeps the wash inside the filled part of the health bar, so it shrinks as the unit takes damage instead of covering the empty section too."] - local gradSize = gradientGroup:AddWidget(GUI:CreateSlider(self.child, L["Gradient Size"], 0.1, 1.0, 0.1, db, "dispelGradientSize", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - gradSize.hideOn = HideDispelOptions - gradSize.disableOn = DisableIfNoGradient - local gradAlpha = gradientGroup:AddWidget(GUI:CreateSlider(self.child, L["Gradient Opacity"], 0.1, 1.0, 0.1, db, "dispelGradientAlpha", function() - InvalidateCurves() - end, function() DF:InvalidateDispelColorCurve(); DF:LightweightUpdateDispelOverlay() end, true), 55) - gradAlpha.hideOn = HideDispelOptions - gradAlpha.disableOn = DisableIfNoGradient - local blendModes = { ["ADD"]= L["Glow (ADD)"], ["BLEND"]= L["Solid (BLEND)"] } - local blendDropdown = gradientGroup:AddWidget(GUI:CreateDropdown(self.child, L["Blend Mode"], blendModes, db, "dispelGradientBlendMode", function() - ApplyDispelSettings() - end), 55) - blendDropdown.hideOn = HideDispelOptions - blendDropdown.disableOn = DisableIfNoGradient - -- Darken effect lives at the bottom of the Gradient group (it only - -- renders behind the gradient). - local darkenCheck = gradientGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Darken Behind Gradient"], db, "dispelGradientDarkenEnabled", function() - self:RefreshStates() - ApplyDispelSettings() - end), 30) - darkenCheck.hideOn = HideDispelOptions - darkenCheck.disableOn = DisableIfNoGradient - darkenCheck.tooltip = L["Dims the frame underneath the wash so the dispel colour reads cleanly over a bright class colour or a busy health bar."] - local darkenAlpha = gradientGroup:AddWidget(GUI:CreateSlider(self.child, L["Darken Amount"], 0.1, 1.0, 0.05, db, "dispelGradientDarkenAlpha", function() - ApplyDispelSettings() - end, function() DF:LightweightUpdateDispelOverlay() end, true), 55) - -- HIDE when the dispel feature is off (variant); GREY when the boolean - -- toggles it depends on are off (disabled-in-place). - darkenAlpha.hideOn = HideIfDisabled - darkenAlpha.disableOn = function(d) - return d.dispelShowGradient == false or not d.dispelGradientDarkenEnabled - end - gradientGroup.hideOn = HideDispelOptions - Add(gradientGroup, nil, 1) - - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "auras_debuffs", label = L["Debuffs"]}, - {pageId = "indicators_highlights", label = L["Highlights"]}, - }), 30, "both") - end) - - -- ======================================== - -- CATEGORY: Profiles - -- ======================================== - CreateCategory("profiles", L["Profiles"]) - - -- ======================================== - -- Profiles > Auto Layouts (Raid only) - -- ======================================== - local pageAutoProfiles = CreateSubTab("profiles", "profiles_auto", L["Auto Layouts"]) - BuildPage(pageAutoProfiles, function(self, db, Add, AddSpace) - if DF.AutoProfilesUI and DF.AutoProfilesUI.BuildPage then - DF.AutoProfilesUI:BuildPage(GUI, self, db, Add, AddSpace) - else - Add(GUI:CreateHeader(self.child, L["Auto Layouts"]), 40, "both") - Add(GUI:CreateLabel(self.child, L["Auto Layouts module not loaded."], 400), 30, "both") - end - end) - - -- Profiles > Manage - local pageManage = CreateSubTab("profiles", "profiles_manage", L["Manage"]) - BuildPage(pageManage, function(self, db, Add, AddSpace, AddSyncPoint) - local currentProfile = DF:GetCurrentProfile() - local profiles = DF:GetProfiles() - - -- Helper to add to current section (for collapsible sections this pattern won't apply, but we use groups) - local currentSection = nil - local function AddToSection(widget, col, colNum) - widget.layoutCol = colNum or col - table.insert(self.children, widget) - end - - -- ============================================ - -- COLUMN 1: Profile List & Creation - -- ============================================ - - -- Current Profile Info Group - local currentGroup = GUI:CreateSettingsGroup(self.child, 280) - currentGroup:AddWidget(GUI:CreateHeader(self.child, L["Current Profile"]), 40) - currentGroup:AddWidget(GUI:CreateLabel(self.child, "|cff00ff00" .. currentProfile .. "|r", 240), 25) - AddToSection(currentGroup, nil, 1) - - -- Available Profiles Group - local listGroup = GUI:CreateSettingsGroup(self.child, 280) - listGroup:AddWidget(GUI:CreateHeader(self.child, L["Available Profiles"]), 40) - - -- Create a container frame for profile list with fixed width and max height - local maxListHeight = 180 - local contentHeight = #profiles * 28 + 10 - local listHeight = math.min(contentHeight, maxListHeight) - local listContainer = CreateFrame("Frame", nil, self.child, "BackdropTemplate") - listContainer:SetSize(240, listHeight) - GUI:CreateElementBackdrop(listContainer, { bgColor = {0, 0, 0, 0.3}, borderColor = {0.3, 0.3, 0.3, 1} }) - listGroup:AddWidget(listContainer, listHeight + 5) - - -- Create scroll frame for the profile list - local profileScroll = CreateFrame("ScrollFrame", nil, listContainer, "ScrollFrameTemplate") - profileScroll:SetPoint("TOPLEFT", 2, -2) - profileScroll:SetPoint("BOTTOMRIGHT", -22, 2) - - GUI.StyleScrollBar(profileScroll) - if contentHeight <= maxListHeight and profileScroll.ScrollBar then - profileScroll.ScrollBar:Hide() - profileScroll:SetPoint("BOTTOMRIGHT", -4, 2) - end - - -- Create scroll child to hold profile buttons - local profileScrollChild = CreateFrame("Frame", nil, profileScroll) - profileScrollChild:SetSize(210, contentHeight) - profileScroll:SetScrollChild(profileScrollChild) - - -- Profile buttons inside scroll child - local py = -3 - for i, p in ipairs(profiles) do - -- Standard theme hover: picking a profile IS the action of this page, - -- so the row gets the same accent wash as any other button rather than - -- the neutral "this is a place" grey. The "this is the active profile" - -- cue is SetActive's accent fill + border, which stays visible under - -- the hover (applyHoverState keeps the active border). - local btn = CreateFrame("Button", nil, profileScrollChild, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 2, py) - DF.GUI:StyleButton(btn, { - width = 206, height = 24, - text = p, font = "DFFontHighlightSmall", - }) - btn:SetActive(p == currentProfile) - btn:SetScript("OnClick", function() - DF:SetProfile(p) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end) - py = py - 28 - end - - AddToSection(listGroup, nil, 1) - - -- Create New Profile Group - local createGroup = GUI:CreateSettingsGroup(self.child, 280) - createGroup:AddWidget(GUI:CreateHeader(self.child, L["Create New Profile"]), 40) - - local input = GUI:CreateInput(self.child, L["Profile Name"], 240) - createGroup:AddWidget(input, 50) - - -- Button row for create actions - local btnRow = CreateFrame("Frame", nil, self.child) - btnRow:SetSize(240, 28) - - local createBtn = GUI:CreateButton(self.child, L["Create Empty"], 115, 24, function() - local text = input.EditBox:GetText() - if not text or text == "" then - DF:Err("Please enter a profile name.") - return - end - DF:SetProfile(text) - input.EditBox:SetText("") - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end) - createBtn:SetParent(btnRow) - createBtn:SetPoint("LEFT", 0, 0) - - local dupeBtn = GUI:CreateButton(self.child, L["Duplicate Current"], 115, 24, function() - local text = input.EditBox:GetText() - if not text or text == "" then - DF:Err("Please enter a name for the duplicated profile.") - return - end - if DF:DuplicateProfile(text) then - input.EditBox:SetText("") - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end - end) - dupeBtn:SetParent(btnRow) - dupeBtn:SetPoint("LEFT", createBtn, "RIGHT", 10, 0) - - createGroup:AddWidget(btnRow, 32) - AddToSection(createGroup, nil, 1) - - -- ============================================ - -- COLUMN 2: Actions & Settings - -- ============================================ - - -- Profile Actions Group - local actionsGroup = GUI:CreateSettingsGroup(self.child, 280) - actionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Profile Actions"]), 40) - - actionsGroup:AddWidget(GUI:CreateIconButton(self.child, "delete", L["Delete Current Profile"], 240, 26, function() - local p = DF:GetCurrentProfile() - if p == "Default" then - DF:Err("Cannot delete Default profile.") - return - end - -- The profile name rides the closure rather than the StaticPopup - -- `data` field it used to be poked onto after the fact. - DF:ShowPopupAlert({ - title = L["Delete Profile"], - message = format(L["Delete profile '%s'?\n\nThis cannot be undone."], p), - buttons = { - { - label = L["Delete"], - onClick = function() - DF:SetProfile("Default") - DF:DeleteProfile(p) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end, nil, "left"), 32) - - actionsGroup:AddWidget(GUI:CreateIconButton(self.child, "refresh", L["Reset Profile to Defaults"], 240, 26, function() - DF:ShowPopupAlert({ - title = L["Reset Profile to Defaults"], - message = L["Reset current profile to defaults?\nThis will reset BOTH Party and Raid settings."], - buttons = { - { - label = L["Reset"], - onClick = function() - DF:ResetFullProfile() - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end, nil, "left"), 32) - - AddToSection(actionsGroup, nil, 2) - - -- Copy Settings Group - local copyGroup = GUI:CreateSettingsGroup(self.child, 280) - copyGroup:AddWidget(GUI:CreateHeader(self.child, L["Copy Settings"]), 40) - copyGroup:AddWidget(GUI:CreateLabel(self.child, L["Copy all settings between Party and Raid modes."], 240), 25) - - -- Both directions are the same confirm with the modes swapped. - local function ConfirmCopyProfile(src, dest, message) - DF:ShowPopupAlert({ - title = L["Copy Settings"], - message = message, - buttons = { - { - label = L["Copy"], - onClick = function() - DF:CopyProfile(src, dest) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end - - copyGroup:AddWidget(GUI:CreateIconButton(self.child, "chevron_right", L["Party to Raid"], 240, 26, function() - ConfirmCopyProfile("party", "raid", - L["Copy Party settings to Raid?\n\nThis will overwrite all Raid settings with your current Party settings."]) - end, nil, "left"), 32) - - copyGroup:AddWidget(GUI:CreateIconButton(self.child, "chevron_right", L["Raid to Party"], 240, 26, function() - ConfirmCopyProfile("raid", "party", - L["Copy Raid settings to Party?\n\nThis will overwrite all Party settings with your current Raid settings."]) - end, nil, "left"), 32) - - AddToSection(copyGroup, nil, 2) - - -- Auto-Switch by Spec Group - local specGroup = GUI:CreateSettingsGroup(self.child, 280) - specGroup:AddWidget(GUI:CreateHeader(self.child, L["Auto-Switch by Spec"]), 40) - - -- Initialize per-character data if needed - if not DandersFramesCharDB then - DandersFramesCharDB = { enableSpecSwitch = false, specProfiles = {} } - end - - local specEnableCb = specGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Spec Auto-Switch"], DandersFramesCharDB, "enableSpecSwitch"), 30) - specEnableCb.keepEnabled = true - -- The enable flag lives on the per-character DB (not the page db arg), so - -- the grey predicate reads DandersFramesCharDB directly. - specGroup.disableChildrenOn = function() return not (DandersFramesCharDB and DandersFramesCharDB.enableSpecSwitch) end - - local numSpecs = GetNumSpecializations and GetNumSpecializations() or 0 - if numSpecs > 0 then - -- Build profile list for dropdown - local pList = { [""]= L["None"] } - for _, p in ipairs(profiles) do - pList[p] = p - end - - if not DandersFramesCharDB.specProfiles then - DandersFramesCharDB.specProfiles = {} - end - - for i = 1, numSpecs do - local _, name = GetSpecializationInfo(i) - if name then - local specIdx = i -- capture for the get/set closures - -- Custom get/set: an unset spec reads back as "" so it displays - -- the "None" option instead of the raw nil ("nil") value. None is - -- stored as nil to keep the DB tidy; CheckProfileAutoSwitch treats - -- both nil and "" as "don't switch". - specGroup:AddWidget(GUI:CreateDropdown(self.child, name, pList, nil, nil, nil, - function() return DandersFramesCharDB.specProfiles[specIdx] or "" end, - function(v) DandersFramesCharDB.specProfiles[specIdx] = (v ~= "" and v) or nil end), 55) - end - end - else - specGroup:AddWidget(GUI:CreateLabel(self.child, L["Specialization data not available."], 240), 25) - end - - AddToSection(specGroup, nil, 2) - - -- See Also links - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "profiles_importexport", label = L["Import/Export"]}, - }), 30, "both") - end) - - -- Profiles > Import/Export - local pageImportExport = CreateSubTab("profiles", "profiles_importexport", L["Import/Export"]) - BuildPage(pageImportExport, function(self, db, Add, AddSpace, AddSyncPoint) - -- Store references - self.exportCheckboxes = {} - self.importCheckboxes = {} - self.exportFrameTypes = {party = true, raid = true} - self.importFrameTypes = {party = true, raid = true} - - -- Derived from the category registry (single source of truth) so this list - -- can never drift from DF.ExportCategories when categories change. - local categoryOrder = {} - for cat in pairs(DF.ExportCategoryInfo) do table.insert(categoryOrder, cat) end - table.sort(categoryOrder, function(a, b) - return (DF.ExportCategoryInfo[a].order or 99) < (DF.ExportCategoryInfo[b].order or 99) - end) - - -- Page-scope note: unlike the rest of the settings window, this page is - -- NOT scoped by the party/raid tab -- exports and imports operate on the - -- whole profile, gated only by the Export for / Import for rows. - local scopeBanner = GUI:CreateInfoBanner(self.child, { - tone = "info", - text = L["Profiles include both Party and Raid settings. Exporting and importing always works on the profile as a whole, no matter which mode tab is selected above. Use the 'Export for' and 'Import for' checkboxes in each column to choose which mode's settings are included."], - }) - Add(scopeBanner, scopeBanner.layoutHeight or 44, "both") - - -- Helper to add to section - local function AddToSection(widget, col, colNum) - widget.layoutCol = colNum or col - table.insert(self.children, widget) - end - - -- Helper to create themed small checkbox - local function CreateSmallCheckbox(parent, label, initialChecked) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(100, 18) - - local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") - cb:SetPoint("LEFT", 0, 0) - GUI:StyleCheckButton(cb, { size = 14, checkSize = 8, themeRoot = parent }) - - local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", cb, "RIGHT", 4, 0) - txt:SetText(label) - txt:SetTextColor(0.85, 0.85, 0.85) - cb.label = txt - - cb:SetChecked(initialChecked or false) - - container.checkbox = cb - container.SetChecked = function(self, val) cb:SetChecked(val) end - container.GetChecked = function(self) return cb:GetChecked() end - container.Enable = function(self) cb:Enable(); container:SetAlpha(1) end - container.Disable = function(self) cb:Disable(); container:SetAlpha(0.35) end - - return container - end - - -- Helper to create small themed button - local function CreateSmallButton(parent, text, width) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { width = width, height = 20, text = text }) - btn.text = btn.Text - return btn - end - - -- ======================================== - -- COLUMN 1: EXPORT - -- ======================================== - - -- "What to Export" group: picks the profile, the mode and the categories. - -- Named for the question it answers -- "Export Settings" read as both - -- "settings for exporting" and "export your settings". - local exportSettingsGroup = GUI:CreateSettingsGroup(self.child, 280) - exportSettingsGroup:AddWidget(GUI:CreateHeader(self.child, L["What to Export"]), 40) - - -- Profile name input - local nameInput = GUI:CreateInput(self.child, L["Profile Name"], 240) - local currentProfileName = (DF.db and DF.db.keys and DF.db.keys.profile) or "My Profile" - nameInput.EditBox:SetText(currentProfileName) - self.exportNameEdit = nameInput.EditBox - exportSettingsGroup:AddWidget(nameInput, 50) - - -- Preset buttons row - local presetRow = CreateFrame("Frame", nil, self.child) - presetRow:SetSize(240, 24) - - -- frameTypes: true = All checks Party+Raid, false = None clears them, - -- nil = Look/Layout leave the frame-type row alone. - local presets = { - {name = "All", x = 0, frameTypes = true, cats = categoryOrder}, - {name = "Look", x = 60, cats = {"bars", "auras", "dispel", "missingBuffs", "defensives", "targetedSpells", "targetedList", "text", "textDesigner", "icons", "other"}}, - {name = "Layout", x = 120, cats = {"position", "layout"}}, - {name = "None", x = 180, frameTypes = false, cats = {}}, - } - - for _, p in ipairs(presets) do - local btn = CreateSmallButton(presetRow, L[p.name], 56) - btn:SetPoint("LEFT", p.x, 0) - btn:SetScript("OnClick", function() - local sel = {} - for _, c in ipairs(p.cats) do sel[c] = true end - for cat, cb in pairs(self.exportCheckboxes) do cb:SetChecked(sel[cat] or false) end - -- All/None also drive the Party/Raid row -- keep the STATE table in - -- sync (SetChecked does not fire the checkbox OnClick handlers). - if p.frameTypes ~= nil and self.exportFrameTypeBoxes then - for ft, box in pairs(self.exportFrameTypeBoxes) do - box:SetChecked(p.frameTypes) - self.exportFrameTypes[ft] = p.frameTypes - end - if self.UpdateExportCategoryState then self.UpdateExportCategoryState() end - end - end) - end - exportSettingsGroup:AddWidget(presetRow, 28) - - -- Frame types row ("Export for" -- the modes whose settings ship; the - -- category list below picks WHICH settings, this row picks WHOSE) - exportSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Export for"], 240), 22) - local ftRow = CreateFrame("Frame", nil, self.child) - ftRow:SetSize(240, 20) - - local partyExp = CreateSmallCheckbox(ftRow, L["Party"], true) - partyExp:SetPoint("LEFT", 0, 0) - partyExp.checkbox:SetScript("OnClick", function(s) - self.exportFrameTypes.party = s:GetChecked() - if self.UpdateExportCategoryState then self.UpdateExportCategoryState() end - end) - - local raidExp = CreateSmallCheckbox(ftRow, L["Raid"], true) - raidExp:SetPoint("LEFT", 80, 0) - raidExp.checkbox:SetScript("OnClick", function(s) - self.exportFrameTypes.raid = s:GetChecked() - if self.UpdateExportCategoryState then self.UpdateExportCategoryState() end - end) - self.exportFrameTypeBoxes = {party = partyExp, raid = raidExp} - exportSettingsGroup:AddWidget(ftRow, 24) - - -- Categories ("Settings to include" -- sub-settings of the modes above) - exportSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Settings to include"], 240), 22) - for _, cat in ipairs(categoryOrder) do - local info = DF.ExportCategoryInfo[cat] - local catRow = CreateFrame("Frame", nil, self.child) - catRow:SetSize(240, 18) - - local cb = CreateSmallCheckbox(catRow, L[info.name], true) - cb:SetPoint("LEFT", 0, 0) - self.exportCheckboxes[cat] = cb - exportSettingsGroup:AddWidget(catRow, 20) - end - - -- Grey the category list while no mode is selected (nothing would - -- export) -- the addon-wide disabled-means-dimmed convention. - self.UpdateExportCategoryState = function() - local enabled = self.exportFrameTypes.party or self.exportFrameTypes.raid - for _, cb in pairs(self.exportCheckboxes) do - if enabled then cb:Enable() else cb:Disable() end - end - end - - AddToSection(exportSettingsGroup, nil, 1) - - -- Export Actions Group - local exportActionsGroup = GUI:CreateSettingsGroup(self.child, 280) - exportActionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Export"]), 40) - - -- Export button - exportActionsGroup:AddWidget(GUI:CreateIconButton(self.child, "upload", L["Generate Export String"], 240, 26, function() - local selectedCats = {} - local allSelected = true - for _, cat in ipairs(categoryOrder) do - if self.exportCheckboxes[cat]:GetChecked() then - table.insert(selectedCats, cat) - else - allSelected = false - end - end - if allSelected then selectedCats = nil end - - local profileName = self.exportNameEdit:GetText() - if profileName == "" then profileName = nil end - - local str = DF:ExportProfile(selectedCats, self.exportFrameTypes, profileName) - if str and self.exportEditBox then - self.exportEditBox:SetText(str) - self.exportEditBox:HighlightText() - self.exportEditBox:SetFocus() - DF:Say("Export generated.") - elseif not str then - DF:Err("Export failed - no string returned") - end - end), 32) - - -- Export text area - local exportScrollContainer = GUI:CreateTextArea(self.child, { width = 240, height = 100 }) - self.exportEditBox = exportScrollContainer.EditBox - - exportActionsGroup:AddWidget(exportScrollContainer, 105) - - -- Select All button - exportActionsGroup:AddWidget(GUI:CreateButton(self.child, L["Select All Text"], 240, 24, function() - if self.exportEditBox then - self.exportEditBox:HighlightText() - self.exportEditBox:SetFocus() - end - end), 28) - - AddToSection(exportActionsGroup, nil, 1) - - -- ======================================== - -- COLUMN 2: IMPORT - -- ======================================== - - -- Import String Group - local importStringGroup = GUI:CreateSettingsGroup(self.child, 280) - importStringGroup:AddWidget(GUI:CreateHeader(self.child, L["Import String"]), 40) - - -- Import text area - local importScrollContainer = GUI:CreateTextArea(self.child, { width = 240, height = 80 }) - self.importEditBox = importScrollContainer.EditBox - - importStringGroup:AddWidget(importScrollContainer, 85) - - -- Parse button - importStringGroup:AddWidget(GUI:CreateButton(self.child, L["Parse String"], 240, 26, function() - if not self.importEditBox then return end - local str = self.importEditBox:GetText() - if not str or str == "" then - DF:Err("Paste a string first.") - return - end - - local importData, errMsg = DF:ValidateImportString(str) - if not importData then - DF:Err(errMsg) - if self.importInfoLabel then self.importInfoLabel:SetText("|cffff6666Error: " .. errMsg .. "|r") end - return - end - - self.parsedImportData = importData - local info = DF:GetImportInfo(importData) - - if self.importInfoLabel then - self.importInfoLabel:SetText(string.format("|cff00ff00" .. L["OK"] .. "|r v%s %s%s", - (tostring(info.version):gsub("^[vV]", "")), - info.hasParty and L["[Party]"] or "", - info.hasRaid and L["[Raid]"] or "")) - end - - if self.importNameEdit and info.profileName then - self.importNameEdit:SetText(info.profileName) - end - - if self.createNewProfileCheck then - self.createNewProfileCheck:Enable() - self.createNewProfileCheck:SetChecked(true) - end - - local availableCats = {} - for _, cat in ipairs(info.detectedCategories) do availableCats[cat] = true end - - for cat, cb in pairs(self.importCheckboxes) do - if availableCats[cat] then cb:Enable(); cb:SetChecked(true) - else cb:Disable(); cb:SetChecked(false) end - end - - if self.importPartyCheck then - if info.hasParty then self.importPartyCheck:Enable() else self.importPartyCheck:Disable() end - self.importPartyCheck:SetChecked(info.hasParty) - end - if self.importRaidCheck then - if info.hasRaid then self.importRaidCheck:Enable() else self.importRaidCheck:Disable() end - self.importRaidCheck:SetChecked(info.hasRaid) - end - - DF:Say("Parsed. Select options and Import.") - end), 30) - - -- Info label - local infoLabel = self.child:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - infoLabel:SetWidth(240) - infoLabel:SetJustifyH("LEFT") - infoLabel:SetText("|cff888888" .. L["Paste string above, then Parse"] .. "|r") - self.importInfoLabel = infoLabel - - local infoContainer = CreateFrame("Frame", nil, self.child) - infoContainer:SetSize(240, 18) - infoLabel:SetParent(infoContainer) - infoLabel:SetPoint("LEFT", 0, 0) - importStringGroup:AddWidget(infoContainer, 22) - - AddToSection(importStringGroup, nil, 2) - - -- "What to Import" group: the target profile, the mode and the categories. - -- Mirrors the export side; see the note there on why "Import Settings" went. - local importSettingsGroup = GUI:CreateSettingsGroup(self.child, 280) - importSettingsGroup:AddWidget(GUI:CreateHeader(self.child, L["What to Import"]), 40) - - -- Profile name input for import - local impNameInput = GUI:CreateInput(self.child, L["Profile Name"], 240) - impNameInput.EditBox:SetText(L["Imported Profile"]) - self.importNameEdit = impNameInput.EditBox - importSettingsGroup:AddWidget(impNameInput, 50) - - -- Create new profile checkbox - local createNewRow = CreateFrame("Frame", nil, self.child) - createNewRow:SetSize(240, 20) - - local createNewCheck = CreateSmallCheckbox(createNewRow, L["Create New Profile"], true) - createNewCheck:SetPoint("LEFT", 0, 0) - createNewCheck:Disable() - self.createNewProfileCheck = createNewCheck - importSettingsGroup:AddWidget(createNewRow, 24) - - -- Frame types row ("Import for" -- which mode receives the settings) - importSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Import for"], 240), 22) - local ftRowImp = CreateFrame("Frame", nil, self.child) - ftRowImp:SetSize(240, 20) - - local partyImp = CreateSmallCheckbox(ftRowImp, L["Party"], false) - partyImp:SetPoint("LEFT", 0, 0) - partyImp:Disable() - partyImp.checkbox:SetScript("OnClick", function(s) self.importFrameTypes.party = s:GetChecked() end) - self.importPartyCheck = partyImp - - local raidImp = CreateSmallCheckbox(ftRowImp, L["Raid"], false) - raidImp:SetPoint("LEFT", 80, 0) - raidImp:Disable() - raidImp.checkbox:SetScript("OnClick", function(s) self.importFrameTypes.raid = s:GetChecked() end) - self.importRaidCheck = raidImp - importSettingsGroup:AddWidget(ftRowImp, 24) - - -- Categories ("Settings to include") - importSettingsGroup:AddWidget(GUI:CreateLabel(self.child, L["Settings to include"], 240), 22) - for _, cat in ipairs(categoryOrder) do - local info = DF.ExportCategoryInfo[cat] - local catRow = CreateFrame("Frame", nil, self.child) - catRow:SetSize(240, 18) - - local cb = CreateSmallCheckbox(catRow, L[info.name], false) - cb:SetPoint("LEFT", 0, 0) - cb:Disable() - self.importCheckboxes[cat] = cb - importSettingsGroup:AddWidget(catRow, 20) - end - - AddToSection(importSettingsGroup, nil, 2) - - -- Import Actions Group - local importActionsGroup = GUI:CreateSettingsGroup(self.child, 280) - importActionsGroup:AddWidget(GUI:CreateHeader(self.child, L["Import"]), 40) - - -- Import button - importActionsGroup:AddWidget(GUI:CreateIconButton(self.child, "download", L["Import Selected"], 240, 26, function() - if not self.parsedImportData then - DF:Err("Parse a string first.") - return - end - - local selectedCats = {} - for _, cat in ipairs(categoryOrder) do - if self.importCheckboxes[cat]:GetChecked() then - table.insert(selectedCats, cat) - end - end - - if #selectedCats == 0 then - DF:Err("Select at least one category.") - return - end - - local selectedFrameTypes = { - party = self.importPartyCheck:GetChecked(), - raid = self.importRaidCheck:GetChecked(), - } - - if not selectedFrameTypes.party and not selectedFrameTypes.raid then - DF:Err("Select Party or Raid.") - return - end - - local createNew = self.createNewProfileCheck and self.createNewProfileCheck:GetChecked() - local profileName = self.importNameEdit and self.importNameEdit:GetText() - if profileName == "" then profileName = nil end - - local confirmText - if createNew then - confirmText = L["Create new profile '"] .. (profileName or L["Imported Profile"]) .. L["'?\n\nThis will copy your current settings, then apply the selected import categories on top."] - else - local currentProfile = DF:GetCurrentProfile() or "Default" - confirmText = L["Import settings into current profile?\n\n"] .. "|c" .. GUI:ToneHex("danger") .. L["WARNING: This will permanently overwrite settings in your '"] .. currentProfile .. L["' profile."] .. "|r\n\n" .. L["Tip: Check 'Create New Profile' to import without affecting your current settings."] - end - - -- Everything the accept needs is captured here rather than stapled - -- onto the dialog afterwards, so there is no window in which the - -- popup exists without its payload. - local importData = self.parsedImportData - DF:ShowPopupAlert({ - title = L["Import Profile"], - message = confirmText, - buttons = { - { - label = L["Import"], - onClick = function() - if not importData then return end - DF:ApplyImportedProfile(importData, selectedCats, selectedFrameTypes, profileName, createNew) - if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - end, - }, - { label = L["Cancel"] }, - }, - }) - end), 32) - - -- Clear button - importActionsGroup:AddWidget(GUI:CreateIconButton(self.child, "close", L["Clear"], 240, 24, function() - if self.importEditBox then self.importEditBox:SetText("") end - if self.importInfoLabel then self.importInfoLabel:SetText("|cff888888" .. L["Paste string above, then Parse"] .. "|r") end - if self.importNameEdit then self.importNameEdit:SetText(L["Imported Profile"]) end - if self.createNewProfileCheck then self.createNewProfileCheck:Disable(); self.createNewProfileCheck:SetChecked(true) end - for _, cb in pairs(self.importCheckboxes) do cb:SetChecked(false); cb:Disable() end - if self.importPartyCheck then self.importPartyCheck:Disable(); self.importPartyCheck:SetChecked(false) end - if self.importRaidCheck then self.importRaidCheck:Disable(); self.importRaidCheck:SetChecked(false) end - self.parsedImportData = nil - end), 28) - - AddToSection(importActionsGroup, nil, 2) - - -- See Also - AddSpace(GUI.Space.block, "both") - Add(GUI:CreateSeeAlso(self.child, { - {pageId = "profiles_manage", label = L["Manage Profiles"]}, - }), 30, "both") - end) - - -- ======================================== - -- CATEGORY: Wizards - -- ======================================== - -- Wizards category hidden for now (builder still in development) - -- CreateCategory("wizards", "Wizards") - - -- Wizards > Setup Wizards (launcher/manager page) — disabled while category is hidden - - -- ======================================== - -- CATEGORY: Debug - -- ======================================== - CreateCategory("debug", L["Debug"]) - - -- Single page containing four collapsible sections in workflow order: - -- Settings -> Categories -> Live Log -> Script Runner. - -- All sections are collapsible and start expanded. - local pageDebugConsole = CreateSubTab("debug", "debug_console", L["Console"]) - BuildPage(pageDebugConsole, function(self, db, Add, AddSpace, AddSyncPoint) - - -- Proxy for dropdown/slider keys (they don't support customGet/customSet) - local debugProxy = setmetatable({}, { - __index = function(_, k) - return DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug[k] - end, - __newindex = function(_, k, v) - if DandersFramesDB_v2 and DandersFramesDB_v2.debug then - DandersFramesDB_v2.debug[k] = v - end - end, - }) - - -- Tracks the currently-open collapsible section so AddToSection() can - -- automatically register subsequent widgets as its children. - local currentSection = nil - - local function AddToSection(widget, height, col) - Add(widget, height, col) - if currentSection then - currentSection:RegisterChild(widget) - end - return widget - end - - -- ============================================================ - -- 1) SETTINGS SECTION - -- ============================================================ - local settingsSection = Add(GUI:CreateCollapsibleSection(self.child, L["Settings"], true), 36, "both") - currentSection = settingsSection - - AddToSection(GUI:CreateCheckbox(self.child, L["Enable Debug Logging"], nil, nil, function() - if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end - end, function() - return DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.enabled or false - end, function(val) - if DF.DebugConsole then - DF.DebugConsole:SetEnabled(val) - elseif DandersFramesDB_v2 and DandersFramesDB_v2.debug then - DandersFramesDB_v2.debug.enabled = val - end - end), 28, "both") - - AddToSection(GUI:CreateCheckbox(self.child, L["Echo to Chat"], nil, nil, nil, function() - return DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.chatEcho or false - end, function(val) - if DandersFramesDB_v2 and DandersFramesDB_v2.debug then - DandersFramesDB_v2.debug.chatEcho = val - end - end), 28, "both") - - local logLevelOptions = { - ["INFO"] = L["Info (All)"], - ["WARN"] = L["Warnings + Errors"], - ["ERROR"] = L["Errors Only"], - } - AddToSection(GUI:CreateDropdown(self.child, L["Minimum Log Level"], logLevelOptions, debugProxy, "logLevel", function() - if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end - end), 55, 1) - - AddToSection(GUI:CreateSlider(self.child, L["Max Log Entries"], 100, 10000, 100, debugProxy, "maxLines", function() - if DF.DebugConsole then - DF.DebugConsole:PruneLog() - DF.DebugConsole:RefreshDisplay() - end - end), 55, 2) - - -- The log lives in SavedVariables, so one left behind is re-read from disk at - -- every login until something clears it. 0 = keep forever, for anyone chasing - -- a bug that only shows up across several days. - -- "0 = never" is in the LABEL because CreateSlider has no value-label map to - -- put it in: parameter 9 is `lightweightUpdate` (a per-drag-tick FUNCTION) and - -- 10 is `usePreviewMode` (the boolean that arms it). Passing a table into - -- either would read as truthy and quietly change how the slider commits. - AddToSection(GUI:CreateSlider(self.child, L["Clear Log After (Days, 0 = Never)"], - 0, 30, 1, debugProxy, "logMaxAgeDays"), 55, 1) - - AddSyncPoint() - - -- ============================================================ - -- 2) LOGGED CATEGORIES SECTION - -- ============================================================ - local categoriesSection = Add(GUI:CreateCollapsibleSection(self.child, L["Logged Categories"], true), 36, "both") - currentSection = categoriesSection - - AddToSection(GUI:CreateNote(self.child, - L["Unchecked categories are not logged at all. Disable noisy categories before reproducing a bug to keep the buffer focused."], - { width = 540 }), 36, "both") - - - local function CollectAllCategories() - local set = {} - if DF.DebugConsole then - for _, g in ipairs(DF.DebugConsole:GetCategoryGroups()) do - for _, cat in ipairs(g.categories) do - set[cat.key] = true - end - end - for cat in pairs(DF.DebugConsole:GetKnownCategories()) do - set[cat] = true - end - end - return set - end - - -- Track all created rows so All/None can refresh their visual state - self.filterRows = {} - local function RefreshAllRows() - for _, row in pairs(self.filterRows) do - if row.RefreshState then row:RefreshState() end - end - if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end - end - - local function SetAllFilters(value) - if not (DandersFramesDB_v2 and DandersFramesDB_v2.debug) then return end - local filters = DandersFramesDB_v2.debug.filters - for cat in pairs(CollectAllCategories()) do - filters[cat] = value - end - RefreshAllRows() - end - - local filterBtnRow = GUI:CreateButtonRow(self.child, { - { label = L["All"], width = 60, onClick = function() SetAllFilters(true) end }, - { label = L["None"], width = 60, onClick = function() SetAllFilters(false) end }, - -- The baseline: everything on except the per-frame firehoses. All/None - -- are blunt; this is the state you actually want to start an - -- investigation from, and the way back after turning things on. - { label = L["Default"], width = 80, - onClick = function() - if DF.DebugConsole and DF.DebugConsole:ApplyDefaultFilters() then - RefreshAllRows() - end - end, - tooltip = { - title = L["Default"], - lines = { - L["Turns every category on except the noisy ones, which log many lines per frame during layout and sorting."], - L["Enable those only while reproducing a layout or sorting bug."], - }, - } }, - }, { height = 22 }) - - AddToSection(filterBtnRow, 28, "both") - - -- One colour for the category-group headings, passed to CreateLabel rather - -- than baked into each string as a |c escape -- an escape inside a - -- localised string is invisible to translators and easy to unbalance. - local GROUP_HEADING_COLOR = { r = 0.93, g = 0.65, b = 0.37 } - - if DF.DebugConsole then - local groups = DF.DebugConsole:GetCategoryGroups() - for _, group in ipairs(groups) do - local groupLabel = L[group.name] or group.name - AddToSection(GUI:CreateLabel(self.child, groupLabel, 540, GROUP_HEADING_COLOR), 22, "both") - for _, cat in ipairs(group.categories) do - -- The firehoses are marked in the row itself, so "why is this - -- one off?" is answered where the user is looking rather than - -- only in the Default button's tooltip. The row renders it as - -- the shared caution icon; it used to be a "(noisy)" suffix - -- concatenated onto the description. - local row = GUI:CreateDebugCategoryRow(self.child, cat.key, cat.desc, 540, cat.noisy) - self.filterRows[cat.key] = row - AddToSection(row, 28, "both") - end - end - - -- Append auto-discovered categories that aren't in the registry - local registered = DF.DebugConsole:GetRegisteredCategorySet() - local known = DF.DebugConsole:GetKnownCategories() - local extras = {} - for cat in pairs(known) do - if not registered[cat] then - tinsert(extras, cat) - end - end - if #extras > 0 then - table.sort(extras) - AddToSection(GUI:CreateLabel(self.child, L["Discovered"], 540, GROUP_HEADING_COLOR), 22, "both") - for _, cat in ipairs(extras) do - local row = GUI:CreateDebugCategoryRow(self.child, cat, nil, 540) - self.filterRows[cat] = row - AddToSection(row, 28, "both") - end - end - end - - AddSyncPoint() - - -- ============================================================ - -- 3) LIVE LOG SECTION - -- ============================================================ - local logSection = Add(GUI:CreateCollapsibleSection(self.child, L["Live Log"], true), 36, "both") - currentSection = logSection - - -- Entry count label - local entryCountLabel = GUI:CreateLabel(self.child, "", 540) - local function UpdateEntryCount() - local count = DF.DebugConsole and DF.DebugConsole:GetLogEntryCount() or 0 - -- No |c escape: CreateLabel's default colour is already the dim body tone. - entryCountLabel:SetText(format(L["Log entries: %d"], count)) - end - UpdateEntryCount() - AddToSection(entryCountLabel, 20, "both") - - -- Action buttons row (Refresh / Clear Log / Copy to Clipboard) - local function CopyLogToClipboard() - if not DF.DebugConsole then return end - -- Was a hand-rolled dialog: ~35 lines building its own frame, backdrop, - -- title, drag handlers and close button. ☠ It also called CreateFrame - -- with the FIXED global name "DFDebugExportPopup" on every click, so a - -- second export built a second frame over the same global and orphaned - -- the first — a leak per click. The shared input popup is a singleton - -- and is the same control the click-cast profile export already uses. - DF:ShowPopupInput({ - title = L["Debug Log Export (Filtered)"], - message = L["Press Ctrl+A to select all, then Ctrl+C to copy"], - text = DF.DebugConsole:GetExportText(), - multiline = true, - readOnly = true, - cancelLabel = L["Close"], - }) - end - - local actionRow = GUI:CreateButtonRow(self.child, { - { label = L["Refresh"], width = 100, onClick = function() - if DF.DebugConsole then - DF.DebugConsole:RefreshDisplay() - UpdateEntryCount() - end - end }, - { label = L["Clear Log"], width = 100, onClick = function() - if DF.DebugConsole then - DF.DebugConsole:ClearLog() - UpdateEntryCount() - end - end }, - { label = L["Copy to Clipboard"], width = 140, onClick = CopyLogToClipboard }, - }) - - AddToSection(actionRow, 32, "both") - - -- Full-width log viewer - local logScrollContainer = GUI:CreateTextArea(self.child, { - width = 540, height = 480, - -- Typing in the log is not an edit — it just re-renders the buffer. - onTextChanged = function(_, userInput) - if userInput and DF.DebugConsole then - DF.DebugConsole:RefreshDisplay() - end - end, - }) - local logEditBox = logScrollContainer.EditBox - - AddToSection(logScrollContainer, 485, "both") - - -- Register live EditBox with DebugConsole - if DF.DebugConsole then - DF.DebugConsole:SetLiveEditBox(logEditBox) - DF.DebugConsole:RefreshDisplay() - UpdateEntryCount() - end - - -- Unregister on page hide - self:SetScript("OnHide", function() - if DF.DebugConsole then - DF.DebugConsole:SetLiveEditBox(nil) - end - end) - - AddSyncPoint() - - -- ============================================================ - -- 4) SCRIPT RUNNER SECTION (developer-only utility, unrelated) - -- ============================================================ - local scriptSection = Add(GUI:CreateCollapsibleSection(self.child, L["Script Runner"], true), 36, "both") - currentSection = scriptSection - - local scriptScrollContainer = GUI:CreateTextArea(self.child, { - width = 540, height = 120, - text = (DandersFramesDB_v2 and DandersFramesDB_v2.debug - and DandersFramesDB_v2.debug.lastScript) or nil, - onTextChanged = function(text, userInput) - if userInput and DandersFramesDB_v2 and DandersFramesDB_v2.debug then - DandersFramesDB_v2.debug.lastScript = text - end - end, - }) - local scriptEditBox = scriptScrollContainer.EditBox - - AddToSection(scriptScrollContainer, 125, "both") - - local scriptStatusLabel = GUI:CreateLabel(self.child, "", 540) - AddToSection(scriptStatusLabel, 20, "both") - - -- Status is a TONE, not an ad-hoc colour: these were five hand-picked hex - -- values that drifted from the info/caution/danger/success language every - -- banner, note and tooltip in the GUI already speaks. ToneHex is the one - -- source for the inline form. - local function SetScriptStatus(text, tone) - if tone then - scriptStatusLabel:SetText("|c" .. GUI:ToneHex(tone) .. text .. "|r") - else - scriptStatusLabel:SetText(text) -- default dim body tone - end - end - - AddToSection(GUI:CreateButton(self.child, L["Run Script"], 540, 26, function() - local code = scriptEditBox:GetText() - if not code or code == "" then - SetScriptStatus(L["No script to run."]) - return - end - local fn, err = loadstring(code) - if not fn then - SetScriptStatus(format(L["Error: %s"], tostring(err)), "danger") - DF:DebugError("SCRIPT", "Compile error: %s", tostring(err)) - return - end - local ok, result = pcall(fn) - if ok then - if result ~= nil then - SetScriptStatus(format(L["Result: %s"], tostring(result)), "info") - else - SetScriptStatus(L["Script executed successfully."], "success") - end - else - SetScriptStatus(format(L["Runtime: %s"], tostring(result)), "danger") - DF:DebugError("SCRIPT", "Runtime error: %s", tostring(result)) - end - end), 32, "both") - - currentSection = nil - end) - -end +local addonName, DF = ... +local format = string.format + +-- ============================================================ +-- GUI PAGE SETUP - Collapsible Category System +-- ============================================================ + +function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) + local L = DF.L + + -- Note-style cross-link dropped in under a "Color by Time Remaining" toggle so users can + -- jump to where the shared, account-wide breakpoint colours actually live (and see that + -- section highlighted on arrival). `group` is the settings group the toggle sits in; `parent` + -- is the page child frame. The link itself is GUI:CreateColorsPageLink (shared with the Aura Designer). + local function AddColorsPageLink(group, parent) + -- Shared note-style cross-link to the Colors page Color-by-Time section (jump + whole- + -- section border flash). CreateLink is fixed-layout, so hand it the group's inner width + -- up front — its wrapped height is then known before AddWidget (the group advances Y by + -- the height we pass). Defined once in GUI:CreateColorsPageLink; shared with the Aura Designer. + local innerW = math.max(40, (group:GetWidth() or 260) - 2 * (group.padding or 10)) + local note = GUI:CreateColorsPageLink(parent, innerW) + group:AddWidget(note, (note.layoutHeight or 16) + 2) + return note + end + + -- Helper function to create a themed "Copy to Raid/Party" button for a + -- section, plus the Sync toggle and the destructive Reset Page button. + -- + -- Only for a section that is a BAG OF SETTINGS spread over many db keys — + -- keeping those two bags in step is the whole point of a persistent link. A + -- section holding ONE key the user can already set directly does not need + -- it: the Aura and Text Designers own one key each (the template name), and + -- their template dropdown IS the sharing control, so they carry neither + -- button. See GUI:CreateDesignerPresetBar. + local function CreateCopyButton(parent, prefixes, sectionName, pageId) + -- Register section in the sync registry + if pageId then + DF.SectionRegistry[pageId] = prefixes + end + + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + btn:SetSize(115, 26) + -- Copy is a normal button; the shared styler owns the backdrop/hover AND + -- the icon+label layout via the icon/text opts. (Label set per-mode below.) + GUI:StyleButton(btn, { + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\content_copy", size = 18, color = { r = 0.9, g = 0.9, b = 0.9 } }, + text = L["Copy to Raid"], + }) + + -- Sync toggle button + local linkBtn + if pageId then + linkBtn = CreateFrame("Button", nil, btn, "BackdropTemplate") + linkBtn:SetSize(120, 26) + -- Snapped gap: the row is a CHAIN (Copy <- Sync <- Reset) and controls + -- are no longer nudged onto the grid after the fact, so the offset + -- itself has to be a whole number of device pixels or every button to + -- the left of this one inherits the fraction. + linkBtn:SetPoint("RIGHT", btn, "LEFT", GUI.SnapLen(linkBtn, -4), 0) + -- Sync is a toggle (SetActive when linked) on the shared styler. + -- fadeActiveText: the synced label recedes slightly while linked. + GUI:StyleButton(linkBtn, { + fadeActiveText = true, + -- icon swaps sync / sync_disabled with the linked state (set in UpdateAppearance) + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync_disabled", size = 18, color = { r = 0.9, g = 0.9, b = 0.9 } }, + text = L["Sync with Raid"], + }) + end + + -- Update appearance based on current mode + local function UpdateAppearance() + local mode = GUI.SelectedMode or "party" + + if mode == "party" then + btn.Text:SetText(L["Copy to Raid"]) + else + btn.Text:SetText(L["Copy to Party"]) + end + -- Content-size so short labels aren't swimming in padding (icon+gap ~18 + ~9px each side). + btn:SetWidth(GUI.SnapLenUp(btn, math.ceil(btn.Text:GetStringWidth()) + 36)) + + -- Copy is a normal button; StyleButton owns its backdrop/hover. + btn.Text:SetTextColor(0.9, 0.9, 0.9) + btn.Icon:SetVertexColor(0.9, 0.9, 0.9) + + -- Update sync button appearance + if linkBtn then + local dest = mode == "party" and L["Raid"] or L["Party"] + local isLinked = DF.db and DF.db.linkedSections and DF.db.linkedSections[pageId] + -- State shown by the toggle border/fill (SetActive) + the label + -- word; text stays white in both states, like the other toggles. + linkBtn:SetActive(isLinked) + linkBtn.Icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. (isLinked and "sync" or "sync_disabled")) + linkBtn.Text:SetText(isLinked and format(L["Synced with %s"], dest) or format(L["Sync with %s"], dest)) + linkBtn.Text:SetTextColor(0.9, 0.9, 0.9) + linkBtn.Icon:SetVertexColor(0.9, 0.9, 0.9) + linkBtn:SetWidth(GUI.SnapLenUp(linkBtn, math.ceil(linkBtn.Text:GetStringWidth()) + 36)) + end + end + + -- Store for refresh + btn.UpdateModeText = UpdateAppearance + btn.rightAlign = true -- Flag for layout system + + -- Copy tooltip (HookScript so it composes with the StyleButton hover). + btn:HookScript("OnEnter", function(self) + local mode = GUI.SelectedMode or "party" + local src = mode == "party" and L["Party"] or L["Raid"] + local dest = mode == "party" and L["Raid"] or L["Party"] + GUI:ShowTooltip(self, { + title = format(L["Copy %s Settings"], sectionName), + lines = { + format(L["Copies these settings from %s to %s."], src, dest), + }, + }) + end) + btn:HookScript("OnLeave", function() + GUI:HideTooltip() + UpdateAppearance() -- keep the Copy/Sync labels fresh after state changes + end) + + btn:SetScript("OnClick", function() + local mode = GUI.SelectedMode or "party" + local dest = mode == "party" and L["Raid"] or L["Party"] + DF:ShowPopupAlert({ + title = format(L["Copy %s Settings"], sectionName), + message = format(L["Copy %s settings to %s?"], sectionName, dest), + buttons = { + { + label = L["Copy"], + onClick = function() + DF:CopySectionSettings(prefixes, mode) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end) + + -- Sync button event handlers + if linkBtn then + linkBtn:HookScript("OnEnter", function(self) + local isLinked = DF.db and DF.db.linkedSections and DF.db.linkedSections[pageId] + if isLinked then + GUI:ShowTooltip(self, { + title = format(L["Synced: %s"], sectionName), + lines = { + format(L["Party & Raid %s settings are synced.\nClick to stop syncing."], sectionName), + }, + }) + else + GUI:ShowTooltip(self, { + title = format(L["Sync: %s"], sectionName), + lines = { + format(L["Click to sync Party & Raid %s settings.\nChanges in one mode will automatically apply to the other."], sectionName), + }, + }) + end + end) + linkBtn:HookScript("OnLeave", function() + GUI:HideTooltip() + UpdateAppearance() -- re-read the synced state so the label updates without /reload + end) + + linkBtn:SetScript("OnClick", function() + if not DF.db then return end + if not DF.db.linkedSections then DF.db.linkedSections = {} end + + if DF.db.linkedSections[pageId] then + DF.db.linkedSections[pageId] = nil + UpdateAppearance() + else + local mode = GUI.SelectedMode or "party" + local dest = mode == "party" and L["Raid"] or L["Party"] + DF:ShowPopupAlert({ + title = format(L["Sync: %s"], sectionName), + message = format(L["Sync %s settings?\n\nThis will copy current %s settings to %s and keep them in sync."], sectionName, sectionName, dest), + buttons = { + { + label = L["Sync"], + onClick = function() + DF.db.linkedSections[pageId] = true + DF:CopySectionSettings(prefixes, mode) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end + end) + end + + -- Reset to defaults button (red, destructive — leftmost in the trio). + -- Static red palette (no theme listener) so it visually flags as + -- destructive vs. the theme-coloured Copy/Sync buttons. + local resetBtn = CreateFrame("Button", nil, btn, "BackdropTemplate") + resetBtn:SetSize(115, 26) + local gap = GUI.SnapLen(resetBtn, -4) -- see the Sync gap above + if linkBtn then + resetBtn:SetPoint("RIGHT", linkBtn, "LEFT", gap, 0) + else + resetBtn:SetPoint("RIGHT", btn, "LEFT", gap, 0) + end + + -- Destructive Reset: the shared danger tone owns the red label/icon + + -- red hover; we keep the content-fit width + the tooltip hook. + GUI:StyleButton(resetBtn, { + tone = "danger", + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = 18 }, + text = L["Reset Page"], + }) + resetBtn:SetWidth(GUI.SnapLenUp(resetBtn, math.ceil(resetBtn.Text:GetStringWidth()) + 36)) + + resetBtn:HookScript("OnEnter", function(self) + local mode = GUI.SelectedMode or "party" + local m = (mode == "party") and L["Party"] or L["Raid"] + GUI:ShowTooltip(self, { + title = format(L["Reset: %s"], sectionName), + lines = { + format(L["Reset %s settings on %s mode to defaults. Other settings are not affected."], sectionName, m), + }, + }) + end) + resetBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) + + resetBtn:SetScript("OnClick", function() + local mode = GUI.SelectedMode or "party" + local m = (mode == "party") and L["Party"] or L["Raid"] + DF:ShowPopupAlert({ + title = format(L["Reset: %s"], sectionName), + message = format(L["Reset %s settings to defaults?\n\nThis only affects %s settings on the current %s mode. This cannot be undone."], sectionName, sectionName, m), + buttons = { + { + label = L["Reset"], + onClick = function() + DF:ResetSectionSettings(prefixes, mode) + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end) + + -- Initial update + UpdateAppearance() + + -- Register for theme updates + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, {UpdateTheme = UpdateAppearance}) + + return btn + end + + -- Expose for use by sub-pages (e.g. Aura Designer) + GUI.CreateCopyButton = CreateCopyButton + + -- Standalone Reset button for pages whose settings aren't mode-specific + -- and so don't get the full Sync/Copy trio. Uses the same red palette and + -- confirmation popup style as the trio's Reset button. + -- + -- parent: widget parent (typically self.child inside BuildPage) + -- sectionName: localised page name, used in the popup and tooltip + -- onReset: callback that performs the actual reset; the helper + -- handles the confirmation popup before invoking this + -- warningText: optional extra line below the standard message (string) + local function CreateResetOnlyButton(parent, sectionName, onReset, warningText) + local resetBtn = CreateFrame("Button", nil, parent, "BackdropTemplate") + resetBtn:SetSize(115, 26) + resetBtn.rightAlign = true + + -- Destructive Reset: shared danger tone (red label/icon + red hover) + + -- content-fit width + tooltip hook. + GUI:StyleButton(resetBtn, { + tone = "danger", + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = 18 }, + text = L["Reset Page"], + }) + resetBtn:SetWidth(GUI.SnapLenUp(resetBtn, math.ceil(resetBtn.Text:GetStringWidth()) + 36)) + + resetBtn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { + title = format(L["Reset: %s"], sectionName), + lines = { + format(L["Reset %s settings to defaults. This cannot be undone."], sectionName), + }, + }) + end) + resetBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) + + resetBtn:SetScript("OnClick", function() + local message = format(L["Reset %s settings to defaults?\n\nThis cannot be undone."], sectionName) + if warningText and warningText ~= "" then + message = format(L["Reset %s settings to defaults?\n\n%s\n\nThis cannot be undone."], sectionName, warningText) + end + DF:ShowPopupAlert({ + title = format(L["Reset: %s"], sectionName), + message = message, + buttons = { + { + label = L["Reset"], + onClick = function() + if onReset then onReset() end + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end, + }, + { label = L["Cancel"] }, + }, + }) + end) + + return resetBtn + end + + GUI.CreateResetOnlyButton = CreateResetOnlyButton + + -- Define category order (updated structure) + GUI.CategoryOrder = {"general", "clickcast", "display", "bars", "text", "auras", "indicators", "profiles", "debug"} + + -- ======================================== + -- CATEGORY: General + -- ======================================== + CreateCategory("general", L["General"]) + + -- ======================================== + -- CATEGORY: Display (new top-level category) + -- ======================================== + CreateCategory("display", L["Display"]) + + -- Display > Visibility + local pageVisibility = CreateSubTab("display", "display_visibility", L["Visibility"]) + BuildPage(pageVisibility, function(self, db, Add, AddSpace, AddSyncPoint) + Add(CreateCopyButton(self.child, {"soloMode", "hidePlayerFrame", "restedIndicator"}, L["Visibility"], "display_visibility"), 25, 2) + + -- ===== FRAME DISPLAY GROUP (Column 1) ===== + local frameDisplayGroup = GUI:CreateSettingsGroup(self.child, 280) + frameDisplayGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Display"]), 40) + + local soloMode = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Solo Mode"], db, "soloMode", function() + DF:UpdateAllFrames() + DF:UpdateDefaultPlayerFrame() + end), 30) + soloMode.hideOn = function() return GUI.SelectedMode == "raid" end + + local restedIndicator = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Rested Indicator"], db, "restedIndicator", function() + DF:UpdateRestedIndicator() + end), 30) + restedIndicator.hideOn = function() return GUI.SelectedMode == "raid" end + restedIndicator.disableOn = function(d) return not d.soloMode end + restedIndicator.tooltip = L["Show rested indicators when in a rested area (inn, city)."] + + local restedIcon = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L[" Show ZZZ Icon"], db, "restedIndicatorIcon", function() + DF:UpdateRestedIndicator() + end), 30) + restedIcon.hideOn = function() return GUI.SelectedMode == "raid" end + restedIcon.disableOn = function(d) return not d.soloMode or not d.restedIndicator end + restedIcon.tooltip = L["Show the animated ZZZ icon on the player frame."] + + local restedGlow = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L[" Show Frame Glow"], db, "restedIndicatorGlow", function() + DF:UpdateRestedIndicator() + end), 30) + restedGlow.hideOn = function() return GUI.SelectedMode == "raid" end + restedGlow.disableOn = function(d) return not d.soloMode or not d.restedIndicator end + restedGlow.tooltip = L["Show a pulsing yellow glow around the frame."] + + local soloNote = frameDisplayGroup:AddWidget(GUI:CreateLabel(self.child, L["Solo Mode: Show your player frame when not in a group."], 250), 30) + soloNote.hideOn = function() return GUI.SelectedMode == "raid" end + + local hidePlayer = frameDisplayGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide Self from Party Frames"], db, "hidePlayerFrame", function() + -- Update the secure header's showPlayer attribute + if not InCombatLockdown() and DF.partyHeader then + DF.partyHeader:SetAttribute("showPlayer", not db.hidePlayerFrame) + end + -- Reapply header settings to reposition frames + if DF.ApplyHeaderSettings then + DF:ApplyHeaderSettings() + end + DF:UpdateAllFrames() + end), 30) + hidePlayer.hideOn = function() return GUI.SelectedMode == "raid" end + hidePlayer.tooltip = L["Removes your player frame from the DandersFrames party display."] + + Add(frameDisplayGroup, nil, 1) + end) + -- The Visibility tab is entirely party/solo-oriented, so hide it in raid mode. + if GUI.Tabs and GUI.Tabs["display_visibility"] then + GUI.Tabs["display_visibility"].partyOnly = true + end + + -- Display > Tooltips (moved from General) + local pageTooltips = CreateSubTab("display", "display_tooltips", L["Tooltips"]) + BuildPage(pageTooltips, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top right (positioned automatically via rightAlign) + Add(CreateCopyButton(self.child, {"tooltip"}, L["Tooltips"], "display_tooltips"), 25, 2) + + -- ⚠ Every box on this page has BOTH an "Anchor To" and an "Anchor", which + -- is the one genuinely confusing thing here: the first picks WHAT the + -- tooltip attaches to, the second WHERE on it. Written once and applied to + -- all five pairs rather than five times over. + local TIP_ANCHOR_TO = L["What the tooltip attaches to. Game Default hands it back to Blizzard's own placement; Cursor follows the mouse; Unit Frame pins it to the frame you are hovering."] + local TIP_ANCHOR_POS = L["Which point of the thing above the tooltip hangs from. Greyed out under Game Default, because Blizzard is placing it."] + + -- Anchor position values (shared) + local anchorPositionValues = { + TOPLEFT = L["Top Left"], + TOP = L["Top"], + TOPRIGHT = L["Top Right"], + LEFT = L["Left"], + CENTER = L["Center"], + RIGHT = L["Right"], + BOTTOMLEFT = L["Bottom Left"], + BOTTOM = L["Bottom"], + BOTTOMRIGHT = L["Bottom Right"], + } + + -- ===== ROW 1: Frame Tooltips + Buff Tooltips ===== + + -- Frame Tooltips (Column 1) + local frameTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + frameTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Tooltips"]), 40) + local frameTooltipEnable = frameTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Frame Tooltips"], db, "tooltipFrameEnabled", nil), 30) + frameTooltipEnable.keepEnabled = true + frameTooltipGroup.disableChildrenOn = function(d) return not d.tooltipFrameEnabled end + frameTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipFrameDisableInCombat", function() end), 30) + + local frameAnchorValues = { + DEFAULT = L["Game Default"], + CURSOR = L["Cursor"], + FRAME = L["Unit Frame"], + } + local frameAnchorTo = frameTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], frameAnchorValues, db, "tooltipFrameAnchor", function() GUI:RefreshCurrentPage() end), 55) + frameAnchorTo.tooltip = TIP_ANCHOR_TO + + local frameAnchorPos = frameTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipFrameAnchorPos", function() end), 55) + frameAnchorPos.disableOn = function(d) return d.tooltipFrameAnchor == "DEFAULT" end + frameAnchorPos.tooltip = TIP_ANCHOR_POS + + local frameOffsetX = frameTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "tooltipFrameX", function() end), 55) + frameOffsetX.disableOn = function(d) return d.tooltipFrameAnchor ~= "FRAME" end + + local frameOffsetY = frameTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "tooltipFrameY", function() end), 55) + frameOffsetY.disableOn = function(d) return d.tooltipFrameAnchor ~= "FRAME" end + + Add(frameTooltipGroup, nil, 1) + + -- Binding Tooltips (Column 2) — pairs with Frame Tooltips: both anchor to + -- the Unit Frame, so they are the two boxes describing the SAME hover. + local bindTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + bindTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Binding Tooltips"]), 40) + local bindTooltipEnable = bindTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Binding Tooltips"], db, "tooltipBindingEnabled", nil), 30) + bindTooltipEnable.keepEnabled = true + bindTooltipGroup.disableChildrenOn = function(d) return not d.tooltipBindingEnabled end + bindTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipBindingDisableInCombat", function() end), 30) + + local bindAnchorValues = { + DEFAULT = L["Game Default"], + CURSOR = L["Cursor"], + FRAME = L["Unit Frame"], + } + local bindAnchorTo = bindTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], bindAnchorValues, db, "tooltipBindingAnchor", function() GUI:RefreshCurrentPage() end), 55) + bindAnchorTo.tooltip = TIP_ANCHOR_TO + + local bindAnchorPos = bindTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipBindingAnchorPos", function() end), 55) + bindAnchorPos.disableOn = function(d) return d.tooltipBindingAnchor == "DEFAULT" end + bindAnchorPos.tooltip = TIP_ANCHOR_POS + + local bindOffsetX = bindTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "tooltipBindingX", function() end), 55) + bindOffsetX.disableOn = function(d) return d.tooltipBindingAnchor ~= "FRAME" end + + local bindOffsetY = bindTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "tooltipBindingY", function() end), 55) + bindOffsetY.disableOn = function(d) return d.tooltipBindingAnchor ~= "FRAME" end + + Add(bindTooltipGroup, nil, 2) + + -- Buff Tooltips (Column 1) + local buffTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + buffTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Buff Tooltips"]), 40) + -- 12.1 factory rows read all of these on the layout-version bump: the Enable + -- toggle is structural (mouse-motion opt-in, in the row sig -> Rebuild), while + -- anchor/offsets/combat-hide ride style.tooltip and restyle in place + -- (SetTooltipAnchorPoint/SetHideTooltipInCombat are live mixin state, 68914+). + local RefreshAuraTooltips = function() + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + DF:UpdateAllFrames() + end + local buffTooltipEnable = buffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Buff Tooltips"], db, "tooltipBuffEnabled", RefreshAuraTooltips), 30) + buffTooltipEnable.keepEnabled = true + buffTooltipGroup.disableChildrenOn = function(d) return not d.tooltipBuffEnabled end + buffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipBuffDisableInCombat", RefreshAuraTooltips), 30) + + local buffAnchorValues = { + DEFAULT = L["Game Default"], + CURSOR = L["Cursor"], + FRAME = L["Buff Icon"], + } + local buffAnchorTo = buffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], buffAnchorValues, db, "tooltipBuffAnchor", function() RefreshAuraTooltips() GUI:RefreshCurrentPage() end), 55) + buffAnchorTo.tooltip = TIP_ANCHOR_TO + + local buffAnchorPos = buffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipBuffAnchorPos", RefreshAuraTooltips), 55) + buffAnchorPos.disableOn = function(d) return d.tooltipBuffAnchor == "DEFAULT" end + buffAnchorPos.tooltip = TIP_ANCHOR_POS + + local buffOffsetX = buffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "tooltipBuffX", RefreshAuraTooltips), 55) + buffOffsetX.disableOn = function(d) return d.tooltipBuffAnchor ~= "FRAME" end + + local buffOffsetY = buffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "tooltipBuffY", RefreshAuraTooltips), 55) + buffOffsetY.disableOn = function(d) return d.tooltipBuffAnchor ~= "FRAME" end + + Add(buffTooltipGroup, nil, 1) + + -- ⚠ NO sync points between these boxes. Every tooltip box bar Resurrection + -- is the same 320 tall by construction (header 40 + enable 30 + combat 30 + -- + two dropdowns at 55 + two sliders at 55), so the two columns stay level + -- on their own — the old "align row N" sync points bought nothing, and one + -- of them actively hurt: column 2 carries Resurrection (70) as a third box, + -- so syncing after it dropped BOTH columns to column 2's bottom and left a + -- ~70px hole in column 1 between Debuff and Binding. + -- + -- 5 boxes of 320 plus one of 70 cannot be split evenly, so the short box + -- belongs at the bottom of the SHORTER column (column 2), where a trailing + -- gap reads as the end of a column rather than as a mistake. + + -- Debuff Tooltips (Column 1) + local debuffTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + debuffTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Debuff Tooltips"]), 40) + local debuffTooltipEnable = debuffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Debuff Tooltips"], db, "tooltipDebuffEnabled", RefreshAuraTooltips), 30) + debuffTooltipEnable.keepEnabled = true + debuffTooltipGroup.disableChildrenOn = function(d) return not d.tooltipDebuffEnabled end + debuffTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipDebuffDisableInCombat", RefreshAuraTooltips), 30) + + local debuffAnchorValues = { + DEFAULT = L["Game Default"], + CURSOR = L["Cursor"], + FRAME = L["Debuff Icon"], + } + local debuffAnchorTo = debuffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], debuffAnchorValues, db, "tooltipDebuffAnchor", function() RefreshAuraTooltips() GUI:RefreshCurrentPage() end), 55) + debuffAnchorTo.tooltip = TIP_ANCHOR_TO + + local debuffAnchorPos = debuffTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipDebuffAnchorPos", RefreshAuraTooltips), 55) + debuffAnchorPos.disableOn = function(d) return d.tooltipDebuffAnchor == "DEFAULT" end + debuffAnchorPos.tooltip = TIP_ANCHOR_POS + + local debuffOffsetX = debuffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -150, 150, 1, db, "tooltipDebuffX", RefreshAuraTooltips), 55) + debuffOffsetX.disableOn = function(d) return d.tooltipDebuffAnchor ~= "FRAME" end + + local debuffOffsetY = debuffTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -150, 150, 1, db, "tooltipDebuffY", RefreshAuraTooltips), 55) + debuffOffsetY.disableOn = function(d) return d.tooltipDebuffAnchor ~= "FRAME" end + + Add(debuffTooltipGroup, nil, 2) + + -- Defensive Icon Tooltips (Column 1) + local defTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + defTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Defensive Icon Tooltips"]), 40) + local defTooltipEnable = defTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Defensive Icon Tooltips"], db, "tooltipDefensiveEnabled", RefreshAuraTooltips), 30) + defTooltipEnable.keepEnabled = true + defTooltipGroup.disableChildrenOn = function(d) return not d.tooltipDefensiveEnabled end + defTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Disable in Combat"], db, "tooltipDefensiveDisableInCombat", RefreshAuraTooltips), 30) + + local defAnchorValues = { + DEFAULT = L["Game Default"], + CURSOR = L["Cursor"], + FRAME = L["Defensive Icon"], + } + local defAnchorTo = defTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor To"], defAnchorValues, db, "tooltipDefensiveAnchor", function() RefreshAuraTooltips() GUI:RefreshCurrentPage() end), 55) + defAnchorTo.tooltip = TIP_ANCHOR_TO + + local defAnchorPos = defTooltipGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorPositionValues, db, "tooltipDefensiveAnchorPos", RefreshAuraTooltips), 55) + defAnchorPos.disableOn = function(d) return d.tooltipDefensiveAnchor == "DEFAULT" end + defAnchorPos.tooltip = TIP_ANCHOR_POS + + local defOffsetX = defTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -100, 100, 1, db, "tooltipDefensiveX", RefreshAuraTooltips), 55) + defOffsetX.disableOn = function(d) return d.tooltipDefensiveAnchor ~= "FRAME" end + + local defOffsetY = defTooltipGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -100, 100, 1, db, "tooltipDefensiveY", RefreshAuraTooltips), 55) + defOffsetY.disableOn = function(d) return d.tooltipDefensiveAnchor ~= "FRAME" end + + Add(defTooltipGroup, nil, 1) + + -- Aura Designer Tooltips (Column 2). Lives HERE rather than on the Aura + -- Designer page: this setting only ever gets touched by someone who + -- wants a tooltip and hasn't got one, or has one and doesn't want it — + -- and both of those people go looking for "tooltip". The AD page links + -- across via its See Also row. + -- + -- Split by AD surface rather than one blanket toggle, because the value + -- differs sharply. GROUPS render whatever matches a filter, so you never + -- chose those icons — that is the case worth having. Indicators and Bars + -- are spells you placed and named yourself, so they gain little, but + -- there's no harm in offering them. + local adTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + adTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Aura Designer Tooltips"]), 40) + local adGroupsTip = adTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Groups"], db, "tooltipADGroupsEnabled", RefreshAuraTooltips), 30) + adGroupsTip.tooltip = L["Filter Groups and Debuff Groups. Their icons come from a filter rather than being placed one by one, so a tooltip is the only way to see what each one is."] + local adIndTip = adTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Indicators"], db, "tooltipADIndicatorsEnabled", RefreshAuraTooltips), 30) + adIndTip.tooltip = L["Icons and squares you placed yourself. You already chose these, so tooltips add less here."] + local adBarTip = adTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Bars"], db, "tooltipADBarsEnabled", RefreshAuraTooltips), 30) + adBarTip.tooltip = L["The Aura Designer bar."] + Add(adTooltipGroup, nil, 2) + + -- Resurrection Icon Tooltips (Column 2) — the one short box, kept last so + -- the leftover space lands at the foot of a column. + local resTooltipGroup = GUI:CreateSettingsGroup(self.child, 280) + resTooltipGroup:AddWidget(GUI:CreateHeader(self.child, L["Resurrection Icon Tooltips"]), 40) + resTooltipGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Resurrection Icon Tooltips"], db, "tooltipResurrectionEnabled", nil), 30) + Add(resTooltipGroup, nil, 2) + + -- Sync point before See Also + AddSyncPoint() + AddSpace(GUI.Space.block, "both") + + -- See Also links + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "auras_buffs", label = L["Buffs"]}, + {pageId = "auras_debuffs", label = L["Debuffs"]}, + {pageId = "auras_defensiveicon", label = L["Defensive Icon"]}, + {pageId = "auras_auradesigner", label = L["Aura Designer"]}, + }), 30, "both") + end) + + -- Display > Fading (moved from Indicators > Out of Range + Dead/Offline fading) + local pageFading = CreateSubTab("display", "display_fading", L["Fading"]) + BuildPage(pageFading, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top right. + -- ☠ These prefixes are matched CASE-SENSITIVELY from the START of the key + -- (DF:SectionOwnsKey), and the SAME list drives Copy, Sync AND Reset Page. + -- "dead" and "offline" matched nothing: the keys are fadeDead*, which begins + -- "fade". So the entire Dead/Offline Fading box was silently dropped from all + -- three. Prefixes must be real key prefixes, not the box's name. + Add(CreateCopyButton(self.child, {"rangeFade", "rangeCheck", "rangeUpdate", "oor", "fadeDead", "healthFade", "hf"}, L["Fading"], "display_fading"), 25, 2) + + -- Element-specific alpha sliders grey out (disabled-in-place) when the + -- "Enable Element-Specific Alpha" toggle is off. The frame-level alpha + -- slider is the alternate variant (shown only when element-specific is + -- off) so it keeps a hideOn, not a grey. + local function HideOOROptions(d) + return not d.oorEnabled + end + + -- Helper to check if frame-level alpha should be hidden (when element-specific is enabled) + local function HideFrameLevelAlpha(d) + return d.oorEnabled + end + + -- ===== OUT OF RANGE GROUP (Column 1) ===== + local oorGroup = GUI:CreateSettingsGroup(self.child, 280) + oorGroup:AddWidget(GUI:CreateHeader(self.child, L["Out of Range"]), 40) + + -- Build dropdown options dynamically + local function GetRangeSpellDropdownOptions() + local options = {} + if DF.GetRangeSpellOptions then + local spellOptions = DF:GetRangeSpellOptions() + for _, opt in ipairs(spellOptions) do + options[opt.value] = opt.label + end + else + options[0] = L["Auto (Spec Default)"] + end + return options + end + + -- Ensure db value is not nil (default to 0 = Auto) + if db.rangeCheckSpellID == nil then + db.rangeCheckSpellID = 0 + end + + -- Helper to refresh info label + local function RefreshRangeInfoLabel() + if self.rangeSpellInfoLabel and self.rangeSpellInfoLabel.SetText and DF.GetCurrentRangeSpellInfo then + local info = DF:GetCurrentRangeSpellInfo() + self.rangeSpellInfoLabel:SetText("|cFFAAAAAA" .. L["Active:"] .. " " .. (info.spellName or "None") .. " (" .. (info.range or "?") .. ")|r") + end + end + + -- Set value callback - called AFTER dropdown has already set db.rangeCheckSpellID + local function SetRangeSpellValue() + local value = db.rangeCheckSpellID or 0 + if DF.SetRangeCheckSpell then + DF:SetRangeCheckSpell(value) + end + RefreshRangeInfoLabel() + if self.rangeSpellInput and self.rangeSpellInput.EditBox then + self.rangeSpellInput.EditBox:SetText("") + end + end + + -- Range Check Spell row + local rangeSpellDropdown = oorGroup:AddWidget(GUI:CreateDropdown(self.child, L["Range Check Spell"], GetRangeSpellDropdownOptions(), db, "rangeCheckSpellID", SetRangeSpellValue), 55) + rangeSpellDropdown.tooltip = L["Select which spell to use for range checking. Auto will use your spec's default healing/friendly spell."] + + -- Custom Spell ID Input + local customSpellInput = oorGroup:AddWidget(GUI:CreateInput(self.child, L["Custom Spell ID"], 120), 55) + customSpellInput.tooltip = L["Enter any spell ID for range checking. Press Enter to apply. Leave empty to use dropdown selection."] + self.rangeSpellInput = customSpellInput + + -- Set initial value if it's a custom spell not in dropdown + if db.rangeCheckSpellID and db.rangeCheckSpellID > 0 then + local isInDropdown = false + if DF.GetRangeSpellOptions then + for _, opt in ipairs(DF:GetRangeSpellOptions()) do + if opt.value == db.rangeCheckSpellID then + isInDropdown = true + break + end + end + end + if not isInDropdown then + customSpellInput.EditBox:SetText(tostring(db.rangeCheckSpellID)) + end + end + + customSpellInput.EditBox:SetNumeric(true) + customSpellInput.EditBox:SetMaxLetters(8) + + local function ApplyCustomSpellID() + local text = customSpellInput.EditBox:GetText() + local spellID = tonumber(text) + + if not text or text == "" then + return + end + + if spellID and spellID > 0 then + local spellName = C_Spell.GetSpellName(spellID) + if spellName then + db.rangeCheckSpellID = spellID + if DF.SetRangeCheckSpell then + DF:SetRangeCheckSpell(spellID) + end + RefreshRangeInfoLabel() + DF:Say("Range spell set to " .. spellName, "ID " .. spellID) + else + DF:Err("Invalid spell ID: " .. spellID) + customSpellInput.EditBox:SetText("") + end + end + end + + customSpellInput.EditBox:SetScript("OnEnterPressed", function(self) + ApplyCustomSpellID() + self:ClearFocus() + end) + customSpellInput.EditBox:SetScript("OnEditFocusLost", function(self) + ApplyCustomSpellID() + end) + + -- Info label showing current active spell + local rangeInfoText = L["Loading..."] + if DF.GetCurrentRangeSpellInfo then + local rangeInfo = DF:GetCurrentRangeSpellInfo() + rangeInfoText = (rangeInfo.spellName or "None") .. " (" .. (rangeInfo.range or "?") .. ")" + end + local infoLabel = oorGroup:AddWidget(GUI:CreateLabel(self.child, "|cFFAAAAAA" .. L["Active:"] .. " " .. rangeInfoText .. "|r", 250), 25) + self.rangeSpellInfoLabel = infoLabel + + -- Range update interval + if db.rangeUpdateInterval == nil then + db.rangeUpdateInterval = 0.5 + end + local intervalSlider = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Range Check Interval"], 0.1, 1.0, 0.05, db, "rangeUpdateInterval", nil, function() + if DF.SetRangeUpdateInterval then + DF:SetRangeUpdateInterval(db.rangeUpdateInterval) + end + end, true), 55) + intervalSlider.tooltip = L["How often to check range (seconds). Lower = more responsive but higher CPU. Default: 0.5s"] + + -- Frame-level alpha (shown when element-specific is disabled) + local frameLevelAlpha = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Alpha (Out of Range)"], 0.1, 1.0, 0.05, db, "rangeFadeAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + frameLevelAlpha.hideOn = HideFrameLevelAlpha + + -- Element-specific toggle + oorGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Element-Specific Alpha"], db, "oorEnabled", function() + self:RefreshStates() + end), 30) + + -- Element-specific sliders (shown when enabled) + local oorHealth = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Bar Alpha"], 0.0, 1.0, 0.05, db, "oorHealthBarAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorHealth.disableOn = HideOOROptions + + local oorMissingHealth = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Missing Health Alpha"], 0.0, 1.0, 0.05, db, "oorMissingHealthAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorMissingHealth.disableOn = HideOOROptions + + local oorBg = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0.0, 1.0, 0.05, db, "oorBackgroundAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorBg.disableOn = HideOOROptions + + local oorBorder = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Border Alpha"], 0.0, 1.0, 0.05, db, "oorBorderAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorBorder.disableOn = HideOOROptions + + -- Unified Text Alpha: the Text Designer now renders all unit text, so a + -- single OOR alpha dims every TD text element (name/health/power/custom) + -- out of range — replacing the old per-element Name/Health text alphas. + local oorText = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Text Alpha"], 0.0, 1.0, 0.05, db, "oorTextAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorText.disableOn = HideOOROptions + + local oorAuras = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Auras Alpha"], 0.0, 1.0, 0.05, db, "oorAurasAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorAuras.disableOn = HideOOROptions + + local oorIcons = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Alpha"], 0.0, 1.0, 0.05, db, "oorIconsAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorIcons.disableOn = HideOOROptions + + local oorDispel = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Dispel Overlay Alpha"], 0.0, 1.0, 0.05, db, "oorDispelOverlayAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorDispel.disableOn = HideOOROptions + + -- My Buff Indicator OOR slider removed — feature deprecated + + local oorPower = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Power Bar Alpha"], 0.0, 1.0, 0.05, db, "oorPowerBarAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorPower.disableOn = HideOOROptions + + local oorMissingBuff = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Missing Buff Alpha"], 0.0, 1.0, 0.05, db, "oorMissingBuffAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorMissingBuff.disableOn = HideOOROptions + + local oorDefensive = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Defensive Icon Alpha"], 0.0, 1.0, 0.05, db, "oorDefensiveIconAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorDefensive.disableOn = HideOOROptions + + -- (Removed) the Targeted Spell Alpha slider on oorTargetedSpellAlpha. Its only + -- consumer was DF:UpdateTargetedSpellAppearance, which faded the group-frame + -- container and went with that display. Personal Targeted is a screen overlay + -- that never ran through ElementAppearance's out-of-range path, and the + -- Targeted List has its own container and colours — so the slider was moving + -- a value nothing read. + + local oorAuraDesigner = oorGroup:AddWidget(GUI:CreateSlider(self.child, L["Aura Designer Alpha"], 0.0, 1.0, 0.05, db, "oorAuraDesignerAlpha", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + oorAuraDesigner.disableOn = HideOOROptions + + Add(oorGroup, nil, 1) + + -- ===== DEAD/OFFLINE FADING GROUP (Column 2) ===== + local deadGroup = GUI:CreateSettingsGroup(self.child, 280) + deadGroup:AddWidget(GUI:CreateHeader(self.child, L["Dead/Offline Fading"]), 40) + + local deadFadeEnable = deadGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Dead Fade"], db, "fadeDeadFrames", function() + self:RefreshStates() + DF:UpdateAllFrames() + DF:RefreshAllVisibleFrames() + end), 30) + deadFadeEnable.keepEnabled = true + deadGroup.disableChildrenOn = function(d) return not d.fadeDeadFrames end + + -- Sliders grey out (disabled-in-place) via deadGroup.disableChildrenOn above. + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Background Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadBackground", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Bar Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadHealthBar", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Name Text Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadName", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Power Bar Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadPowerBar", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Icons Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadIcons", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Auras Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadAuras", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + deadGroup:AddWidget(GUI:CreateSlider(self.child, L["Status Text Alpha"], 0.0, 1.0, 0.05, db, "fadeDeadStatusText", function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 55) + + deadGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Custom Dead Background"], db, "fadeDeadUseCustomColor", function() + self:RefreshStates() + DF:UpdateAllFrames() + DF:RefreshAllVisibleFrames() + end), 30) + + -- Colour picker also greys on the useCustomColor variant (disableOn composes + -- with the group's enable gate). + local deadBgColor = deadGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Dead Background Color"], db, "fadeDeadBackgroundColor", false, function() DF:RefreshAllVisibleFrames() end, function() DF:RefreshAllVisibleFrames() end, true), 35) + deadBgColor.disableOn = function(d) return not d.fadeDeadUseCustomColor end + + Add(deadGroup, nil, 2) + + -- ===== HEALTH THRESHOLD FADING (col2) ===== + -- Column width, and the spacer above it is column 2 as well. A "both" + -- widget takes the LOWER of the two columns and drops both to it, so a + -- "both" spacer here would push column 2 down past the bottom of the + -- out-of-range group in column 1 and leave a hole under Dead/Offline. + -- + -- Column 2 rather than 1 because column 1 carries the out-of-range group + -- and its long stack of per-element sliders, far and away the tallest + -- thing on the page. + AddSpace(GUI.Space.block, 2) + local hfGroup = GUI:CreateSettingsGroup(self.child, 280) + hfGroup:AddWidget(GUI:CreateHeader(self.child, L["Health Threshold Fading"]), 40) + + local hfEnable = hfGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Health Threshold Fade"], db, "healthFadeEnabled", function() + self:RefreshStates() + DF:UpdateAllFrames() + DF:RefreshAllVisibleFrames() + end), 30) + hfEnable.keepEnabled = true + -- Was set on hfGroup, which is a SettingsGroup and has no tooltip support, + -- so this explanation had never once been seen. It belongs on the enable + -- toggle anyway — that's the control you hover to ask "what is this?". + hfEnable.tooltip = L["Fade frames or elements when a unit's health is above the set threshold (e.g. 100% or 80%)."] + hfGroup.disableChildrenOn = function(d) return not d.healthFadeEnabled end + + local hfThreshold = hfGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Threshold (%)"], 50, 100, 1, db, "healthFadeThreshold", function() + DF:UpdateAllFrames() + DF:RefreshAllVisibleFrames() + end), 55) + hfThreshold.tooltip = L["Units at or above this health percent are faded."] + + hfGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Cancel Fade on Dispellable Debuff"], db, "hfCancelOnDispel", function() + DF:UpdateAllFrames() + DF:RefreshAllVisibleFrames() + end), 30) + + -- Health fade sliders need UpdateAllFrameAppearances to force an immediate visual refresh. + -- Unlike OOR/dead fade which refresh on range/state changes, health fade alpha values + -- are only re-read during appearance updates, not triggered by FullFrameRefresh alone. + local function RefreshHealthFade() + if DF.InvalidateHealthFadeCurve then DF:InvalidateHealthFadeCurve() end + DF:RefreshAllVisibleFrames() + if DF.UpdateAllFrameAppearances then DF:UpdateAllFrameAppearances() end + end + + local hfFrameAlpha = hfGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Alpha (Above Threshold)"], 0.1, 1.0, 0.05, db, "healthFadeAlpha", nil, RefreshHealthFade, true), 55) + hfFrameAlpha.tooltip = L["Frame opacity when health is above the threshold."] + + Add(hfGroup, nil, 2) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "display_visibility", label = L["Visibility"]}, + -- LEGACY-TEXT-CLEANUP: legacy text page hidden; link removed + -- {pageId = "text_status", label = L["Status Text"]}, + }), 30, "both") + end) + + -- Display > Pet Frames + local pagePets = CreateSubTab("display", "display_pets", L["Pet Frames"]) + BuildPage(pagePets, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top right + Add(CreateCopyButton(self.child, {"pet"}, L["Pet Frames"], "display_pets"), 25, 2) + + -- Check modes for conditional content + local isGroupedMode = db.petGroupMode == "GROUPED" + local isRaidMode = GUI.SelectedMode == "raid" + + -- ===== GENERAL GROUP (col1) ===== + -- Column width, not full width. A full-width box belongs to a page that + -- genuinely needs the room -- Pinned Frames, Nicknames -- and everything + -- below these two here is ordinary two-column controls, so stretching + -- them across the top reads as two different layouts stacked together. + local generalGroup = GUI:CreateSettingsGroup(self.child, 280) + generalGroup:AddWidget(GUI:CreateHeader(self.child, L["Pet Frame Settings"]), 40) + local petEnable = generalGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Pet Frames"], db, "petEnabled", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + self:RefreshStates() + end), 30) + petEnable.keepEnabled = true + generalGroup.disableChildrenOn = function(d) return not d.petEnabled end + -- No slot height on the blurbs here or in the group below: at 250 they + -- wrap to more lines than they did at 530, and CreateLabel measures + -- itself whenever the call site does not pin it. Guessing a replacement + -- number by hand is what puts a blurb through the control beneath it. + generalGroup:AddWidget(GUI:CreateLabel(self.child, L["Show health bars for player and party/raid member pets, anchored to their owner's frame. Pet frames hide when owner dies."], 250)) + Add(generalGroup, nil, 1) + + -- ===== LAYOUT MODE GROUP (col1) ===== + local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) + layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout Mode"]), 40) + layoutGroup.disableChildrenOn = function(d) return not d.petEnabled end + + local groupModeValues = { + ATTACHED = L["Attached to Owner"], + GROUPED = L["Separate Pet Group"], + } + local petLayoutMode = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Layout Mode"], groupModeValues, db, "petGroupMode", function() + if DF.UpdateAllPetFrames then DF:UpdateAllPetFrames(true) end + if DF.UpdateAllRaidPetFrames then DF:UpdateAllRaidPetFrames(true) end + GUI:RefreshCurrentPage() + end), 55) + petLayoutMode.tooltip = L["Attached puts each pet beside its owner's frame, so you read them together. Separate Pet Group collects every pet into one block you can place anywhere. The rest of this page changes to match your choice."] + + if not isGroupedMode then + layoutGroup:AddWidget(GUI:CreateLabel(self.child, L["Pet frames are positioned relative to their owner's frame."], 250)) + else + layoutGroup:AddWidget(GUI:CreateLabel(self.child, L["Pet frames are grouped together in a separate container."], 250)) + end + Add(layoutGroup, nil, 1) + + -- GROUPED MODE: Group Settings (col1) + if isGroupedMode then + local groupedSettingsGroup = GUI:CreateSettingsGroup(self.child, 280) + groupedSettingsGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Settings"]), 40) + groupedSettingsGroup.disableChildrenOn = function(d) return not d.petEnabled end + + local groupAnchorValues = { + BOTTOM = isRaidMode and L["Below Raid"] or L["Below Party"], + TOP = isRaidMode and L["Above Raid"] or L["Above Party"], + LEFT = isRaidMode and L["Left of Raid"] or L["Left of Party"], + RIGHT = isRaidMode and L["Right of Raid"] or L["Right of Party"], + } + local updateFunc = isRaidMode + and function() if DF.UpdateRaidPetGroupLayout then DF:UpdateRaidPetGroupLayout() end end + or function() if DF.UpdatePetGroupLayout then DF:UpdatePetGroupLayout() end end + + local petGroupPos = groupedSettingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Group Position"], groupAnchorValues, db, "petGroupAnchor", updateFunc), 55) + petGroupPos.tooltip = L["Which side of your party or raid frames the whole pet block sits on. Use the offsets below to nudge it from there."] + + local growthValues = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } + groupedSettingsGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], growthValues, db, "petGroupGrowth", updateFunc), 55) + groupedSettingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Spacing"], 0, 20, 1, db, "petGroupSpacing", updateFunc, updateFunc, true), 55) + groupedSettingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Group X Offset"], -100, 100, 1, db, "petGroupOffsetX", updateFunc, updateFunc, true), 55) + groupedSettingsGroup:AddWidget(GUI:CreateSlider(self.child, L["Group Y Offset"], -100, 100, 1, db, "petGroupOffsetY", updateFunc, updateFunc, true), 55) + + if isRaidMode then + groupedSettingsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Group Label"], db, "petGroupShowLabel", function() + if DF.UpdateRaidPetGroupLayout then DF:UpdateRaidPetGroupLayout() end + end), 30) + end + + Add(groupedSettingsGroup, nil, 1) + end + + -- SIZE GROUP (col1) + local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) + sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Size"]), 40) + sizeGroup.disableChildrenOn = function(d) return not d.petEnabled end + + if not isGroupedMode then + local petMatchW = sizeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Match Owner Width"], db, "petMatchOwnerWidth", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + GUI:RefreshCurrentPage() + end), 30) + petMatchW.tooltip = L["Sizes each pet frame to its owner's, so the pair stays aligned when you resize the unit frames. The Width slider below greys out while this is on."] + local petMatchH = sizeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Match Owner Height"], db, "petMatchOwnerHeight", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + GUI:RefreshCurrentPage() + end), 30) + petMatchH.tooltip = L["Sizes each pet frame to its owner's, so the pair stays aligned when you resize the unit frames. The Height slider below greys out while this is on."] + end + + local widthSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Width"], 40, 150, 1, db, "petFrameWidth", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + if not isGroupedMode then + widthSlider.disableOn = function(d) return d.petMatchOwnerWidth end + end + + local heightSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Height"], 10, 40, 1, db, "petFrameHeight", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + if not isGroupedMode then + heightSlider.disableOn = function(d) return d.petMatchOwnerHeight end + end + + Add(sizeGroup, nil, 1) + + -- APPEARANCE GROUP (col2) + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + appearanceGroup.disableChildrenOn = function(d) return not d.petEnabled end + appearanceGroup:AddWidget(GUI:CreateTextureDropdown(self.child, L["Health Bar Texture"], db, "petTexture", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + appearanceGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Background Color"], db, "petBackgroundColor", true, function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) + Add(appearanceGroup, nil, 2) + + -- ===== BORDER GROUP (Stage 4.3) ===== + -- include set tailored for a mini unit frame's border. Skipped: + -- animate (decoration, not alert), offset (Pet Frame has its own + -- Offset X / Y in the Position group in column 1), class / role colour + -- (UnitClass("pet") returns the pet family, not a class token), + -- colour-by-time / colour-by-type (no aura-state context). + local petBorderGroup = GUI:CreateSettingsGroup(self.child, 280) + petBorderGroup:AddWidget(GUI:CreateHeader(self.child, L["Border"]), 40) + petBorderGroup.disableChildrenOn = function(d) return not d.petEnabled end + GUI:CreateBorderControls(petBorderGroup, db, "pet", { + parent = self.child, + include = { alpha = true, inset = true, blendMode = true, + gradient = true, shadow = true }, + fullUpdate = function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, + lightUpdate = function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, + lightColors = function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, + refreshStates = function() self:RefreshStates() end, + sizeMin = 1, sizeMax = 6, sizeStep = 1, + }) + Add(petBorderGroup, nil, 2) + + -- HEALTH BAR GROUP (col2) + local healthBarGroup = GUI:CreateSettingsGroup(self.child, 280) + healthBarGroup:AddWidget(GUI:CreateHeader(self.child, L["Health Bar"]), 40) + healthBarGroup.disableChildrenOn = function(d) return not d.petEnabled end + + local healthColorValues = { + GREEN = L["Always Green"], + CLASS = L["Class Color"], + HEALTH = L["Health Gradient"], + CUSTOM = L["Custom Color"], + } + healthBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Health Bar Color"], healthColorValues, db, "petHealthColorMode", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + GUI:RefreshCurrentPage() + end), 55) + + local customHealthColor = healthBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Custom Health Color"], db, "petHealthColor", false, function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) + customHealthColor.hideOn = function(d) return d.petHealthColorMode ~= "CUSTOM" end + + healthBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Health Percentage"], db, "petShowHealthText", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end), 30) + + healthBarGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Power Bar"], db, "petShowPowerBar", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + GUI:RefreshCurrentPage() + end), 30) + + local petPowerHeight = healthBarGroup:AddWidget(GUI:CreateSlider(self.child, L["Power Bar Height"], 1, 12, 1, db, "petPowerBarHeight", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + petPowerHeight.disableOn = function(d) return not d.petShowPowerBar end -- grey when power bar off + + local powerColorValues = { + POWER = L["By Power Type"], + CUSTOM = L["Custom Color"], + } + local petPowerColorMode = healthBarGroup:AddWidget(GUI:CreateDropdown(self.child, L["Power Bar Color"], powerColorValues, db, "petPowerColorMode", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + GUI:RefreshCurrentPage() + end), 55) + petPowerColorMode.disableOn = function(d) return not d.petShowPowerBar end -- grey when power bar off + + local customPowerColor = healthBarGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Custom Power Color"], db, "petPowerColor", false, function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) + -- Grey when the power bar is off (boolean fold); HIDE only for the non-CUSTOM + -- colour mode (variant gating). The two compose: hidden in non-CUSTOM, greyed + -- in CUSTOM while the power bar is off. + customPowerColor.disableOn = function(d) return not d.petShowPowerBar end + customPowerColor.hideOn = function(d) return d.petPowerColorMode ~= "CUSTOM" end + + Add(healthBarGroup, nil, 2) + + -- NAME TEXT GROUP (col2) + local textAnchorValues = { + TOPLEFT= L["Top Left"], TOP= L["Top"], TOPRIGHT= L["Top Right"], + LEFT= L["Left"], CENTER= L["Center"], RIGHT= L["Right"], + BOTTOMLEFT= L["Bottom Left"], BOTTOM= L["Bottom"], BOTTOMRIGHT= L["Bottom Right"], + } + + local nameTextGroup = GUI:CreateSettingsGroup(self.child, 280) + nameTextGroup:AddWidget(GUI:CreateHeader(self.child, L["Name Text"]), 40) + nameTextGroup.disableChildrenOn = function(d) return not d.petEnabled end + nameTextGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "petNameFont", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 16, 1, db, "petNameFontSize", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + nameTextGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "petNameFontOutline", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + nameTextGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "petNameFontOutline", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 30) + nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Max Name Length"], 4, 20, 1, db, "petNameMaxLength", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end), 55) + nameTextGroup:AddWidget(GUI:CreateDropdown(self.child, L["Name Anchor"], textAnchorValues, db, "petNameAnchor", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + nameTextGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Name Text Color"], db, "petNameColor", false, function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) + nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Name X Offset"], -30, 30, 1, db, "petNameX", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + nameTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Name Y Offset"], -15, 15, 1, db, "petNameY", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + Add(nameTextGroup, nil, 2) + + -- POSITION GROUP (col1, Attached mode only) + if not isGroupedMode then + local positionGroup = GUI:CreateSettingsGroup(self.child, 280) + positionGroup:AddWidget(GUI:CreateHeader(self.child, L["Position"]), 40) + positionGroup.disableChildrenOn = function(d) return not d.petEnabled end + + local anchorValues = { + BOTTOM = L["Below Owner"], + TOP = L["Above Owner"], + LEFT = L["Left of Owner"], + RIGHT = L["Right of Owner"], + } + positionGroup:AddWidget(GUI:CreateDropdown(self.child, L["Anchor"], anchorValues, db, "petAnchor", function() + if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end + end), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -50, 50, 1, db, "petOffsetX", function() + if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end + end, function() if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end end, true), 55) + positionGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -50, 50, 1, db, "petOffsetY", function() + if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end + end, function() if DF.UpdateAllPetFramePositions then DF:UpdateAllPetFramePositions() end end, true), 55) + + Add(positionGroup, nil, 1) + end + + -- HEALTH TEXT GROUP (col2) + local healthTextGroup = GUI:CreateSettingsGroup(self.child, 280) + healthTextGroup:AddWidget(GUI:CreateHeader(self.child, L["Health Text"]), 40) + healthTextGroup.disableChildrenOn = function(d) return not d.petEnabled end + healthTextGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Font"], db, "petHealthFont", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + healthTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Font Size"], 6, 14, 1, db, "petHealthFontSize", function() + if DF.ApplyPetSettings then DF:ApplyPetSettings() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + healthTextGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Outline"], db, "petHealthFontOutline", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + healthTextGroup:AddWidget(GUI:CreateShadowCheckbox(self.child, L["Shadow"], db, "petHealthFontOutline", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 30) + healthTextGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Health Text Color"], db, "petHealthTextColor", false, function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 35) + healthTextGroup:AddWidget(GUI:CreateDropdown(self.child, L["Health Text Anchor"], textAnchorValues, db, "petHealthAnchor", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end), 55) + healthTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Health X Offset"], -30, 30, 1, db, "petHealthX", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + healthTextGroup:AddWidget(GUI:CreateSlider(self.child, L["Health Y Offset"], -15, 15, 1, db, "petHealthY", function() + if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end + end, function() if DF.LightweightUpdatePetFrames then DF:LightweightUpdatePetFrames() end end, true), 55) + Add(healthTextGroup, nil, 2) + end) + + -- General > Settings (mode enable/disable, Blizzard frame toggles, profile-wide settings) + local pageGeneral = CreateSubTab("general", "general_settings", L["Settings"]) + BuildPage(pageGeneral, function(self, db, Add, AddSpace, AddSyncPoint) + -- Helpers: read from party-mode storage (canonical), write to BOTH + -- party and raid mode dbs so the value stays consistent regardless + -- of which mode is currently selected. The Blizzard frames are + -- global UI elements so the toggle conceptually has no mode. + local function makeBlizGet(key) + return function() return DF.db.party and DF.db.party[key] end + end + local function makeBlizSet(key, cb) + return function(val) + if DF.db.party then DF.db.party[key] = val end + if DF.db.raid then DF.db.raid[key] = val end + if cb then cb() end + end + end + + -- Contextual reload popup shown when toggling DF Party/Raid frames. + -- Offers an optional third button that ALSO flips the matching + -- Blizzard hide flag, so "enabling" DF party also disables Blizzard + -- party (typical intent) and "disabling" DF party also enables + -- Blizzard party (so the user isn't left with no frames). + local function PromptReloadAfterModeToggle(mode) + if not DF:EnableFlagsDifferFromLoaded() then return end + if not DF.ShowPopupAlert then return end + + -- NB: don't use `cond and a or b` here — the `a` result can be + -- false (when DF frames are disabled), which makes the `or` + -- fall through to the wrong mode's value. + local enabled + if mode == "party" then + enabled = DF.db.partyEnabled ~= false + else + enabled = DF.db.raidEnabled ~= false + end + local blizKey = (mode == "party") and "hideBlizzardPartyFrames" or "hideBlizzardRaidFrames" + local blizCurrentlyHidden = DF.db.party and DF.db.party[blizKey] + + local buttons = {} + if enabled and not blizCurrentlyHidden then + -- Enabling DF frames while Blizzard frames are still visible + -- → offer to disable the Blizzard equivalent on the same reload + buttons[#buttons + 1] = { + label = (mode == "party") and L["Reload & Disable Blizzard Party"] or L["Reload & Disable Blizzard Raid"], + onClick = function() + if DF.db.party then DF.db.party[blizKey] = true end + if DF.db.raid then DF.db.raid[blizKey] = true end + ReloadUI() + end, + } + elseif (not enabled) and blizCurrentlyHidden then + -- Disabling DF frames while Blizzard frames are hidden + -- → offer to re-enable the Blizzard equivalent + buttons[#buttons + 1] = { + label = (mode == "party") and L["Reload & Enable Blizzard Party"] or L["Reload & Enable Blizzard Raid"], + onClick = function() + if DF.db.party then DF.db.party[blizKey] = false end + if DF.db.raid then DF.db.raid[blizKey] = false end + ReloadUI() + end, + } + end + buttons[#buttons + 1] = { label = L["Just Reload"], onClick = function() ReloadUI() end } + buttons[#buttons + 1] = { label = L["Reload Later"] } + + DF:ShowPopupAlert({ + title = L["Reload Required"], + message = L["Enabling or disabling a frame mode requires a UI reload to take effect.\n\nReload now?"], + width = 560, + buttonWidth = 170, + buttonHeight = 44, + buttons = buttons, + }) + end + + -- The Blizzard-frame disable is applied ONCE at load (a hard, ElvUI-style + -- UnregisterAllEvents + reparent of the CompactRaidFrameManager that can't + -- be cleanly undone live), so changing any of these toggles needs a UI + -- reload to take full effect. The setter still hides/shows the frames + -- immediately for feedback; this prompt handles the permanent part. + local function PromptReloadBlizzard() + if not DF.ShowPopupAlert then return end + DF:ShowPopupAlert({ + title = L["Reload Required"], + message = L["Disabling or enabling the Blizzard frames requires a UI reload to take full effect.\n\nReload now?"], + buttons = { + { label = L["Reload Now"], onClick = function() ReloadUI() end }, + { label = L["Reload Later"] }, + }, + }) + end + + -- ===== INFO BANNER (global settings notice) ===== + do + local banner = GUI:CreateInfoBanner(self.child, { + tone = "info", + text = L["Settings on this page apply globally — changes persist across both the Party and Raid sections."], + }) + Add(banner, banner.layoutHeight, "both") + end + + -- ===== FRAME MODES GROUP (Column 1, Top) ===== + local modesGroup = GUI:CreateSettingsGroup(self.child, 280) + modesGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Modes"]), 40) + modesGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Party Frames"], DF.db, "partyEnabled", function() PromptReloadAfterModeToggle("party") end), 30) + modesGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Raid Frames"], DF.db, "raidEnabled", function() PromptReloadAfterModeToggle("raid") end), 30) + modesGroup:AddWidget(GUI:CreateLabel(self.child, + L["Completely enable or disable the Party or Raid frame system. Disabled modes are never created, consuming zero performance in the background. Requires a UI reload to apply."], + 260), 80) + Add(modesGroup, nil, 1) + + -- ===== BLIZZARD FRAMES GROUP (Column 1, Bottom) ===== + -- Storage stays per-mode (party + raid both updated via setter sync) + -- so AutoProfiles and ExportCategories continue to work unchanged. + local blizzardGroup = GUI:CreateSettingsGroup(self.child, 280) + blizzardGroup:AddWidget(GUI:CreateHeader(self.child, L["Blizzard Frames"]), 40) + + local disablePartyCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( + self.child, L["Disable Blizzard Party Frames"], + DF.db.party, "hideBlizzardPartyFrames", + function() PromptReloadBlizzard() end, + makeBlizGet("hideBlizzardPartyFrames"), + makeBlizSet("hideBlizzardPartyFrames", function() DF:UpdateBlizzardFrameVisibility() end) + ), 30) + disablePartyCheck.tooltip = L["Hides and unregisters all events on the default Blizzard party frames so they consume no performance."] + + local disableRaidCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( + self.child, L["Disable Blizzard Raid Frames"], + DF.db.party, "hideBlizzardRaidFrames", + function() PromptReloadBlizzard() end, + makeBlizGet("hideBlizzardRaidFrames"), + makeBlizSet("hideBlizzardRaidFrames", function() DF:UpdateBlizzardFrameVisibility() end) + ), 30) + disableRaidCheck.tooltip = L["Hides and unregisters all events on the default Blizzard raid frames so they consume no performance."] + + local disablePlayerCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( + self.child, L["Hide Blizzard Player Frame"], + nil, nil, + function() DF:UpdateDefaultPlayerFrame() end, + makeBlizGet("hideDefaultPlayerFrame"), + makeBlizSet("hideDefaultPlayerFrame", function() DF:UpdateDefaultPlayerFrame() end), + "hideDefaultPlayerFrame" + ), 30) + disablePlayerCheck.tooltip = L["Hides the default Blizzard player portrait and health bar."] + + -- Visual divider + small caption to separate the related sub-option + -- (Show Side Menu only applies once a Blizzard frame is disabled) + local divider = CreateFrame("Frame", nil, self.child) + divider:SetSize(260, 1) + local dividerTex = divider:CreateTexture(nil, "OVERLAY") + dividerTex:SetColorTexture(1, 1, 1, 0.08) + dividerTex:SetPoint("LEFT", 0, 0) + dividerTex:SetPoint("RIGHT", 0, 0) + dividerTex:SetHeight(1) + blizzardGroup:AddWidget(divider, 14) + + local sideMenuCheck = blizzardGroup:AddWidget(GUI:CreateCheckbox( + self.child, L["Show Party/Raid Side Menu"], + DF.db.party, "showBlizzardSideMenu", + function() PromptReloadBlizzard() end, + makeBlizGet("showBlizzardSideMenu"), + makeBlizSet("showBlizzardSideMenu", function() DF:UpdateBlizzardFrameVisibility() end) + ), 30) + sideMenuCheck.disableOn = function() + local p = DF.db.party + return not (p and (p.hideBlizzardPartyFrames or p.hideBlizzardRaidFrames)) + end + sideMenuCheck.tooltip = L["Shows the ping wheel & party management menu when Blizzard frames are disabled."] + + Add(blizzardGroup, nil, 1) + + -- ===== MINIMAP GROUP (Column 1) ===== + -- The minimap button is a single global UI element (no mode), so it lives + -- here rather than the per-mode Visibility page. Reads party-canonical and + -- writes both dbs so it stays consistent regardless of selected mode. + local minimapGroup = GUI:CreateSettingsGroup(self.child, 280) + minimapGroup:AddWidget(GUI:CreateHeader(self.child, L["Minimap"]), 40) + minimapGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show Minimap Button"], nil, nil, function() + DF:UpdateMinimapButton() + end, makeBlizGet("showMinimapButton"), makeBlizSet("showMinimapButton"), "showMinimapButton"), 30) + Add(minimapGroup, nil, 1) + + -- ===== RENDERING GROUP (Column 1) ===== + -- Pixel-Perfect Scaling is a render-quality flag read by every frame and + -- element in BOTH modes (Frames/Core.lua GetPixelScale + 60-odd db.pixelPerfect + -- reads), so it's global — read party-canonical, write both mode dbs (same + -- pattern as the Blizzard/Minimap toggles above) — and lives here rather than + -- on the per-mode Frame page. + local function refreshPixelPerfect() + -- Re-apply header sizing + refresh the live frames (UpdateAllFrames auto- + -- routes party vs raid by the real in-world context) plus any test frames. + if DF.headersInitialized and DF.ApplyHeaderSettings then DF:ApplyHeaderSettings() end + if DF.UpdateAllFrames then DF:UpdateAllFrames() end + if (DF.testMode or DF.raidTestMode) and DF.RefreshTestFramesWithLayout then + DF:RefreshTestFramesWithLayout() + end + end + local renderingGroup = GUI:CreateSettingsGroup(self.child, 280) + renderingGroup:AddWidget(GUI:CreateHeader(self.child, L["Rendering"]), 40) + renderingGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Pixel-Perfect Scaling"], + nil, nil, refreshPixelPerfect, + makeBlizGet("pixelPerfect"), makeBlizSet("pixelPerfect"), "pixelPerfect"), 30) + -- Label slots below are sized for the WRAPPED text plus a gap. Labels are + -- variable-height widgets (GUI.RowHeight only governs fixed ones), so the + -- slot is whatever is passed here — too small and the next widget's label + -- sits on the last line of this one. + renderingGroup:AddWidget(GUI:CreateLabel(self.child, + L["Snaps sizes and borders to exact pixels for crisp rendering."], 250), 42) + -- Pixel-perfect scale hint: at a UI Scale of 768/physicalHeight, one UI unit + -- equals one physical pixel, so snapping has nothing to round away and borders + -- are at their crispest. Tell the user that value (and whether they're already + -- there) — purely informational, we never change their scale for them. + do + local function computeScaleHint() + local _, physH = GetPhysicalScreenSize() + local recScale = (physH and physH > 0) and (768 / physH) or 1 + local pp = (DF.GetPixelScale and DF:GetPixelScale()) or 1 + if math.abs(pp - 1) < 0.01 then + return L["Your UI Scale is already pixel-perfect for this resolution."] + end + return string.format( + L["Tip: for the crispest result at this resolution, set your UI Scale to %.4f — type /console UIScale %.4f to apply it (it may be below the in-game slider's minimum)."], + recScale, recScale) + end + local scaleHint = GUI:CreateLabel(self.child, computeScaleHint(), 250) + -- Recompute on page refresh so the hint isn't stale after a resolution or + -- UI-scale change (GetPixelScale is re-cached on those events). Idempotent + -- SetText — only writes when the text actually changed — so no relayout loop. + scaleHint.refreshContent = function() + local t = computeScaleHint() + if t ~= scaleHint._dfLastHint then + scaleHint._dfLastHint = t + scaleHint:SetText(t) + end + end + -- 3 wrapped lines (~48px) + a clear gap before the dropdown below. + renderingGroup:AddWidget(scaleHint, 72) + end + -- Aura duration-text update rate (account-wide, DF.GlobalDefaults). Feeds the + -- native duration binding at bind time (creation-frozen), so a change is + -- structural: invalidate the memoized value + re-drive rows AND the Aura + -- Designer (the ApplyColorByTime pattern — the other global folded into the + -- aura struct sigs). + local auraDurRateValues = { + SMOOTH = L["Smooth"], NORMAL = L["Normal"], PERFORMANCE = L["Performance"], + _order = { "SMOOTH", "NORMAL", "PERFORMANCE" }, + } + renderingGroup:AddWidget(GUI:CreateDropdown(self.child, L["Aura Duration Update Rate"], + auraDurRateValues, DF:GetGlobalDB(), "auraDurationUpdateInterval", function() + if DF.InvalidateAuraDurationUpdateInterval then DF:InvalidateAuraDurationUpdateInterval() end + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + if DF.UpdateAllFrames then DF:UpdateAllFrames() end + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end), 55) + renderingGroup:AddWidget(GUI:CreateLabel(self.child, + L["How often aura countdown text refreshes. Smooth updates ten times a second, Performance once a second. Normal keeps the standard rate."], + 250), 52) + Add(renderingGroup, nil, 1) + + -- ===== SETTINGS PANEL APPEARANCE GROUP (Column 2, Top) ===== + -- Controls the look of this settings panel itself — does NOT affect + -- in-game frame text (use Health Text / Name Text pages for those). + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Settings Panel Appearance"]), 40) + appearanceGroup:AddWidget(GUI:CreateFontDropdown(self.child, L["Settings Font"], DF.db, "settingsFont", function() + if GUI.RefreshSettingsFont then GUI:RefreshSettingsFont() end + end), 55) + appearanceGroup:AddWidget(GUI:CreateOutlineDropdown(self.child, L["Settings Font Outline"], DF.db, "settingsFontOutline", function() + if GUI.RefreshSettingsFont then GUI:RefreshSettingsFont() end + end), 55) + appearanceGroup:AddWidget(GUI:CreateLabel(self.child, + L["Font used for this settings panel. Does not affect in-game frame text — use the Text Designer for those."], + 260), 60) + Add(appearanceGroup, nil, 2) + + -- ===== LANGUAGE GROUP (Column 2, Bottom) ===== + local languageValues = { + AUTO = L["Auto (use client language)"], + enUS = "English", + deDE = "Deutsch", + esES = "Español (ES)", + esMX = "Español (MX)", + frFR = "Français", + itIT = "Italiano", + koKR = "한국어", + ptBR = "Português (BR)", + ruRU = "Русский", + zhCN = "中文 (简体)", + zhTW = "中文 (繁體)", + } + local languageGroup = GUI:CreateSettingsGroup(self.child, 280) + languageGroup:AddWidget(GUI:CreateHeader(self.child, L["Language"]), 40) + -- Language override lives on the per-character SavedVariable so + -- locale files can read it at file-load time (before DF.db exists). + languageGroup:AddWidget(GUI:CreateDropdown(self.child, L["Addon Language"], languageValues, DandersFramesCharDB, "languageOverride", function() + if DF.ShowPopupAlert then + DF:ShowPopupAlert({ + title = L["Reload Required"], + message = L["Changing the addon language requires a UI reload to take effect.\n\nReload now?"], + buttons = { + { label = L["Reload Now"], onClick = function() ReloadUI() end }, + { label = L["Later"] }, + }, + }) + end + end), 55) + languageGroup:AddWidget(GUI:CreateLabel(self.child, + L["Override the addon's display language. Auto follows your WoW client language. Translations are community-contributed and may be incomplete."], + 260), 60) + Add(languageGroup, nil, 2) + + -- ===== NOTIFICATIONS GROUP (Column 2, Bottom) ===== + local notificationsGroup = GUI:CreateSettingsGroup(self.child, 280) + notificationsGroup:AddWidget(GUI:CreateHeader(self.child, L["Notifications"]), 40) + notificationsGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Notify me when a newer version is available"], + DF:GetGlobalDB(), "notifyOutdated", function() + -- Setting applies immediately; no extra callback needed. + end), 30) + Add(notificationsGroup, nil, 2) + end) + + -- General > Frame + local pageFrame = CreateSubTab("general", "general_frame", L["Frame"]) + BuildPage(pageFrame, function(self, db, Add, AddSpace, AddSyncPoint) + -- Copy button at top + -- "background"/"missingHealth" belong to bars_health (which hosts all + -- controls for those keys) — not registered here so its Copy/Sync/Reset + -- solely owns them. + -- "permanentMover" (16 keys) and the two growth keys were reached by nothing: + -- the whole permanent mover was skipped by Copy, Sync and Reset. ("border" and + -- "anchor" match nothing either — the real keys are frameBorder* / frameAnchor*, + -- already covered by "frame"; left in place as harmless intent.) + -- + -- ⚠ DELIBERATELY NOT LISTED: the 14 raid* layout keys (raidUseGroups, + -- raidPlayersPerRow, raidGroup*, raidFlat*, raidRowColSpacing...). They are + -- per-mode and so DO exist on the party side, but only the raid page ever + -- edits them — so party's copies sit at their untouched defaults. One list + -- drives Copy, Sync AND Reset, so owning them would make "Copy to Raid" from + -- the party page overwrite the user's raid layout with those defaults. The + -- cost of leaving them out is that Reset Page does not clear raid layout; + -- that is the lesser of the two, and fixing it properly needs per-direction + -- ownership, which SectionOwnsKey does not currently express. + Add(CreateCopyButton(self.child, {"frame", "permanentMover", "growDirection", "growthAnchor", "border", "anchor"}, L["Frame"], "general_frame"), 25, 2) + + -- Migration: Ensure new flat raid settings have defaults + if db.raidFlatGrowthAnchor == nil then db.raidFlatGrowthAnchor = "START" end + if db.raidFlatFrameAnchor == nil then db.raidFlatFrameAnchor = "START" end + if db.raidFlatColumnAnchor == nil then db.raidFlatColumnAnchor = "START" end + + -- Function to update the correct frames based on mode + local function UpdateFrames() + -- Invalidate the raid layoutSig optimization cache BEFORE + -- ApplyHeaderSettings runs, so this cycle's ApplyRaidGroupSorting + -- applies layout settings that aren't tracked by layoutSig (notably + -- raidGroupRowGrowth) instead of bailing. (PR #134) + if GUI.SelectedMode == "raid" then + DF._lastRaidLayoutSig = nil + DF._raidSortApplied = false + end + if DF.headersInitialized then + DF:ApplyHeaderSettings() + end + if GUI.SelectedMode == "raid" then + DF:UpdateRaidLayout() + if DF.SecureSort and DF.SecureSort.raidFramesRegistered then + DF.SecureSort:PushRaidLayoutConfig() + DF.SecureSort:PushRaidGroupLayoutConfig() + DF.SecureSort:TriggerSecureRaidSort() + end + -- Update test mode frames if active + if DF.raidTestMode then DF:UpdateRaidTestFrames() end + else + DF:UpdateAllFrames() + end + end + + -- Store references to sliders so we can update their labels + local groupsPerRowSlider, rowColSpacingSlider, playersPerRowSlider + + -- Function to update dynamic labels based on growth direction + local function UpdateDynamicLabels() + if groupsPerRowSlider and groupsPerRowSlider.label then + groupsPerRowSlider.label:SetText(db.growDirection == "VERTICAL" and L["Groups Per Column"] or L["Groups Per Row"]) + end + if rowColSpacingSlider and rowColSpacingSlider.label then + rowColSpacingSlider.label:SetText(db.growDirection == "VERTICAL" and L["Column Spacing"] or L["Row Spacing"]) + end + if playersPerRowSlider and playersPerRowSlider.label then + playersPerRowSlider.label:SetText(db.growDirection == "VERTICAL" and L["Players Per Column"] or L["Players Per Row"]) + end + end + + -- Custom callback for growth direction + local function OnGrowthDirectionChanged() + UpdateDynamicLabels() + UpdateFrames() + if GUI.SelectedMode == "raid" and not db.raidUseGroups and not InCombatLockdown() then + C_Timer.After(0, function() + if not InCombatLockdown() then + if DF.headersInitialized then DF:ApplyHeaderSettings() end + if DF.UpdateRaidLayout then DF:UpdateRaidLayout() end + end + end) + end + -- Defer label repositioning so headers have settled into new direction first + C_Timer.After(0, function() + if DF.UpdateRaidGroupLabels then + DF:UpdateRaidGroupLabels() + end + end) + -- Rebuild the page so orientation-dependent dropdown TITLES refresh + -- live (e.g. "Columns Grow From" vs "Rows Grow From") without needing + -- to reopen the settings window. Dropdowns bake their label at build + -- time, so a rebuild is the only way to update it. (Option VALUES are + -- now the static "Start (Left/Top)" / "End (Right/Bottom)" form, so + -- only the titles need refreshing.) Deferred so it runs after the + -- triggering dropdown's own click handler has finished unwinding. + C_Timer.After(0, function() + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end) + end + + -- Column 1 is the layout chain -- size, direction, raid mode, and + -- whichever group detail that mode implies. Column 2 keeps + -- Appearance at the top, where styling sits on every other page. + -- + -- Six boxes against three is not the imbalance it looks: FIVE of + -- the left column's boxes are raid-only, so in party mode the page + -- is Frame Size + Layout Direction against Appearance + Permanent + -- Mover. Permanent Mover is also by far the biggest box here, which + -- carries column 2 in raid. + -- ===== FRAME SIZE GROUP (Column 1) ===== + local sizeGroup = GUI:CreateSettingsGroup(self.child, 280) + sizeGroup:AddWidget(GUI:CreateHeader(self.child, L["Frame Size"]), 40) + sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Width"], 60, 300, 1, db, "frameWidth", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) + sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Height"], 20, 300, 1, db, "frameHeight", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) + sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Padding"], 0, 10, 1, db, "framePadding", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) + sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Scale"], 0.5, 2.0, 0.05, db, "frameScale", function() DF:UpdateContainerPosition() DF:UpdateRaidContainerPosition() UpdateFrames() end, function() DF:LightweightUpdateFrameScale() end, true), 55) + local frameSpacingSlider = sizeGroup:AddWidget(GUI:CreateSlider(self.child, L["Frame Spacing"], -5, 50, 1, db, "frameSpacing", UpdateFrames, function() DF:LightweightUpdateFrameSpacing() end, true), 55) + frameSpacingSlider.hideOn = function() return GUI.SelectedMode == "raid" and not db.raidUseGroups end + Add(sizeGroup, nil, 1) + + -- ===== APPEARANCE GROUP (Column 2) ===== + local appearanceGroup = GUI:CreateSettingsGroup(self.child, 280) + appearanceGroup:AddWidget(GUI:CreateHeader(self.child, L["Appearance"]), 40) + -- Canonical border controls via the unified helper. Replaces the + -- previous hand-rolled Show / Color / Style / Texture / Size block. + -- classColor + roleColor are now first-class helper include flags (no + -- bespoke "Use Class Color" extra needed). (Pixel-Perfect Scaling moved + -- to General > Settings > Rendering — it's a global, mode-agnostic flag.) + GUI:CreateBorderControls(appearanceGroup, db, "frame", { + parent = self.child, + include = { + -- Frame Border is the outer chrome of the unit. It's a + -- structural element, not an alert surface, so animations + -- don't fit the design — removed in Stage 4.0 after Stage + -- 3 used it as a dev playground. + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, + classColor = true, roleColor = true, + alpha = true, + }, + fullUpdate = function() UpdateFrames() DF:LightweightUpdateBorder() end, + lightUpdate = function() DF:LightweightUpdateBorder() end, + lightColors = function() DF:LightweightUpdateBorderColor() end, + refreshStates = function() if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end end, + sizeMin = 1, sizeMax = 16, sizeStep = 1, + }) + Add(appearanceGroup, nil, 2) + + -- ===== LAYOUT DIRECTION GROUP (Column 1) ===== + local layoutGroup = GUI:CreateSettingsGroup(self.child, 280) + layoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Layout Direction"]), 40) + + -- Party dropdown + local partyGrowOptions = { HORIZONTAL= L["Rows"], VERTICAL= L["Columns"] } + local partyArrangeDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], partyGrowOptions, db, "growDirection", OnGrowthDirectionChanged), 55) + partyArrangeDropdown.hideOn = function() return GUI.SelectedMode == "raid" end + + -- Raid GROUP dropdown (groups mode) + local raidGrowOptions = { HORIZONTAL= L["Columns"], VERTICAL= L["Rows"] } + local raidArrangeDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], raidGrowOptions, db, "growDirection", OnGrowthDirectionChanged), 55) + raidArrangeDropdown.hideOn = function() return GUI.SelectedMode ~= "raid" or not db.raidUseGroups end + + -- Raid FLAT dropdown + local flatGrowOptions = { HORIZONTAL= L["Rows"], VERTICAL= L["Columns"] } + local flatArrangeDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Growth Direction"], flatGrowOptions, db, "growDirection", OnGrowthDirectionChanged), 55) + flatArrangeDropdown.hideOn = function() return GUI.SelectedMode ~= "raid" or db.raidUseGroups end + + -- Growth anchor (party only) + local anchorOptions = { START= L["Start (Left/Top)"], CENTER= L["Center"], END= L["End (Right/Bottom)"] } + local anchorDropdown = layoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Frames Grow From"], anchorOptions, db, "growthAnchor", UpdateFrames), 55) + anchorDropdown.hideOn = function() return GUI.SelectedMode == "raid" end + + Add(layoutGroup, nil, 1) + + -- ===== RAID LAYOUT MODE GROUP (Column 1, raid only) ===== + local raidModeGroup = GUI:CreateSettingsGroup(self.child, 280) + raidModeGroup:AddWidget(GUI:CreateHeader(self.child, L["Raid Layout Mode"]), 40) + raidModeGroup.hideOn = function() return GUI.SelectedMode ~= "raid" end + + raidModeGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Use Group-Based Layout"], db, "raidUseGroups", function() + UpdateFrames() + if DF.SecureSort then + DF.SecureSort:PushRaidGroupLayoutConfig() + DF.SecureSort:TriggerSecureRaidSort() + end + -- Branch on the SETTING only. Folding `not InCombatLockdown()` into this + -- test meant that switching TO flat mode while in combat fell into the + -- else branch and disabled flat mode outright -- the opposite of what + -- the user asked for. SetEnabled already defers correctly in combat + -- (it queues pendingVisibility and replays at PLAYER_REGEN_ENABLED), so + -- just tell it the truth and let it schedule. + if not db.raidUseGroups then + if not InCombatLockdown() and DF.UpdateRaidGroupLabels then DF:UpdateRaidGroupLabels() end + C_Timer.After(0, function() + if DF.FlatRaidFrames then + if not DF.FlatRaidFrames.initialized and not InCombatLockdown() then + DF.FlatRaidFrames:Initialize() + end + if DF.FlatRaidFrames.initialized then DF.FlatRaidFrames:SetEnabled(true) end + end + if not InCombatLockdown() then + if DF.headersInitialized then DF:ApplyHeaderSettings() end + if DF.UpdateRaidLayout then DF:UpdateRaidLayout() end + end + end) + else + if DF.FlatRaidFrames and DF.FlatRaidFrames.initialized then + DF.FlatRaidFrames:SetEnabled(false) + end + end + if GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + end), 30) + + raidModeGroup:AddWidget(GUI:CreateLabel(self.child, L["Enabled: Players organized by raid groups (1-8).\nDisabled: All players in one flat grid."], 250), 45) + Add(raidModeGroup, nil, 1) + + -- ===== GROUP LAYOUT SETTINGS (Column 1, raid+groups only) ===== + local groupLayoutGroup = GUI:CreateSettingsGroup(self.child, 280) + groupLayoutGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Layout Settings"]), 40) + groupLayoutGroup.hideOn = function() return GUI.SelectedMode ~= "raid" or not db.raidUseGroups end + + local groupLayoutHint = db.growDirection == "VERTICAL" and L["Players stack horizontally, groups grow top-to-bottom."] or L["Players stack vertically, groups grow left-to-right."] + groupLayoutGroup:AddWidget(GUI:CreateLabel(self.child, groupLayoutHint, 250), 25) + + groupLayoutGroup:AddWidget(GUI:CreateSlider(self.child, L["Group Spacing"], -5, 100, 1, db, "raidGroupSpacing", UpdateFrames, function() DF:LightweightUpdateRaidLayout() end, true), 55) + + local rowColLabel = db.growDirection == "VERTICAL" and L["Column Spacing"] or L["Row Spacing"] + rowColSpacingSlider = groupLayoutGroup:AddWidget(GUI:CreateSlider(self.child, rowColLabel, -5, 100, 1, db, "raidRowColSpacing", UpdateFrames, function() DF:LightweightUpdateRaidLayout() end, true), 55) + + local groupsLabel = db.growDirection == "VERTICAL" and L["Groups Per Column"] or L["Groups Per Row"] + groupsPerRowSlider = groupLayoutGroup:AddWidget(GUI:CreateSlider(self.child, groupsLabel, 1, 8, 1, db, "raidGroupsPerRow", UpdateFrames, function() DF:LightweightUpdateRaidLayout() end, true), 55) + + groupLayoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Group Alignment"], anchorOptions, db, "raidGroupAnchor", UpdateFrames), 55) + + local rowGrowLabel = db.growDirection == "VERTICAL" and L["Columns Grow From"] or L["Rows Grow From"] + local rowGrowOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } + groupLayoutGroup:AddWidget(GUI:CreateDropdown(self.child, rowGrowLabel, rowGrowOptions, db, "raidGroupRowGrowth", UpdateFrames), 55) + + -- Players Grow From = the direction players fill the group's main axis. + -- HORIZONTAL groups stack players vertically (Top/Bottom); VERTICAL groups + -- stack players horizontally (Left/Right). Values map to START/END. + local playerAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } + groupLayoutGroup:AddWidget(GUI:CreateDropdown(self.child, L["Players Grow From"], playerAnchorOptions, db, "raidPlayerAnchor", UpdateFrames), 55) + + Add(groupLayoutGroup, nil, 1) + + -- ===== GROUP VISIBILITY (Column 1, raid only) ===== + local groupVisGroup = GUI:CreateSettingsGroup(self.child, 280) + groupVisGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Visibility"]), 40) + groupVisGroup.hideOn = function() return GUI.SelectedMode ~= "raid" end + + groupVisGroup:AddWidget(GUI:CreateLabel(self.child, L["Choose which groups to display."], 250), 25) + + -- Initialize raidGroupVisible if it doesn't exist + if not db.raidGroupVisible then + db.raidGroupVisible = {[1]=true,[2]=true,[3]=true,[4]=true,[5]=true,[6]=true,[7]=true,[8]=true} + end + + for i = 1, 8 do + local groupIndex = i + local overrideKey = "raidGroupVisible_" .. i + groupVisGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Group"] .. " " .. i, nil, nil, + function() + if db.raidUseGroups then + -- Separated mode + DF:UpdateRaidHeaderVisibility(); DF:PositionRaidHeaders() + else + -- Flat mode - rebuild groupFilter and nameList + if DF.FlatRaidFrames then + DF.FlatRaidFrames:UpdateContainerSize() + DF.FlatRaidFrames:UpdateSorting() + end + end + UpdateFrames() + end, + function() return db.raidGroupVisible[groupIndex] ~= false end, + function(val) db.raidGroupVisible[groupIndex] = val end, + overrideKey + ), 25) + end + + Add(groupVisGroup, nil, 1) + + -- ===== GROUP DISPLAY ORDER (Column 2, raid+groups only) ===== + local groupOrderGroup = GUI:CreateSettingsGroup(self.child, 280) + groupOrderGroup:AddWidget(GUI:CreateHeader(self.child, L["Group Display Order"]), 40) + groupOrderGroup.hideOn = function() return GUI.SelectedMode ~= "raid" or not db.raidUseGroups end + + groupOrderGroup:AddWidget(GUI:CreateLabel(self.child, L["Drag to reorder groups. Top = first."], 250), 25) + + local playerGroupFirstCheck = groupOrderGroup:AddWidget(GUI:CreateCheckbox(self.child, L["My Group First"], db, "raidPlayerGroupFirst", function() + if DF.UpdatePlayerGroupTracking then DF:UpdatePlayerGroupTracking() end + if DF.UpdateRaidGroupOrderAttributes then DF:UpdateRaidGroupOrderAttributes() end + DF:TriggerRaidPosition() + UpdateFrames() + end), 25) + playerGroupFirstCheck.tooltip = L["When enabled, the group you are in will always be displayed first."] + + -- Initialize raidGroupDisplayOrder if it doesn't exist + if not db.raidGroupDisplayOrder then + db.raidGroupDisplayOrder = {1, 2, 3, 4, 5, 6, 7, 8} + end + + local groupOrderWidget = GUI:CreateGroupOrderList(self.child, db, "raidGroupDisplayOrder", function() + if DF.UpdateRaidGroupOrderAttributes then DF:UpdateRaidGroupOrderAttributes() end + DF:TriggerRaidPosition() + UpdateFrames() + end) + groupOrderGroup:AddWidget(groupOrderWidget, 230) + + Add(groupOrderGroup, nil, 2) + + -- ===== FLAT GRID SETTINGS (Column 1, raid+flat only) ===== + local flatGridGroup = GUI:CreateSettingsGroup(self.child, 280) + flatGridGroup:AddWidget(GUI:CreateHeader(self.child, L["Flat Grid Settings"]), 40) + flatGridGroup.hideOn = function() return GUI.SelectedMode ~= "raid" or db.raidUseGroups end + + flatGridGroup:AddWidget(GUI:CreateLabel(self.child, L["All players in a unified grid. Sorting applies raid-wide."], 250), 25) + + local function UpdateFlatLayoutFull() + if InCombatLockdown() then return end + if DF.headersInitialized then DF:ApplyHeaderSettings() end + if GUI.SelectedMode == "raid" then DF:UpdateRaidLayout() end + end + + local playersPerLabel = db.growDirection == "VERTICAL" and L["Players Per Column"] or L["Players Per Row"] + playersPerRowSlider = flatGridGroup:AddWidget(GUI:CreateSlider(self.child, playersPerLabel, 1, 40, 1, db, "raidPlayersPerRow", UpdateFlatLayoutFull, UpdateFlatLayoutFull, true), 55) + + local growthAnchorOptions = { START= L["Start (Left/Top)"], CENTER= L["Center"], END= L["End (Right/Bottom)"] } + flatGridGroup:AddWidget(GUI:CreateDropdown(self.child, L["Grid Alignment"], growthAnchorOptions, db, "raidFlatGrowthAnchor", UpdateFrames), 55) + + -- Columns/Rows Grow From = the direction the grid wraps (secondary axis). + -- VERTICAL (Columns) wraps left/right; HORIZONTAL (Rows) wraps top/bottom. + local flatColumnLabel = db.growDirection == "VERTICAL" and L["Columns Grow From"] or L["Rows Grow From"] + local flatColumnOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } + flatGridGroup:AddWidget(GUI:CreateDropdown(self.child, flatColumnLabel, flatColumnOptions, db, "raidFlatColumnAnchor", UpdateFrames), 55) + + -- Players Grow From = the direction players fill the grid's main axis. + -- HORIZONTAL (Rows) fills Left/Right; VERTICAL (Columns) fills Top/Bottom. + -- Replaces the old "Reverse Order" checkbox; START/END values are identical. + local flatFillOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } + flatGridGroup:AddWidget(GUI:CreateDropdown(self.child, L["Players Grow From"], flatFillOptions, db, "raidFlatFrameAnchor", UpdateFrames), 55) + + flatGridGroup:AddWidget(GUI:CreateSlider(self.child, L["Horizontal Spacing"], -5, 100, 1, db, "raidFlatHorizontalSpacing", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) + flatGridGroup:AddWidget(GUI:CreateSlider(self.child, L["Vertical Spacing"], -5, 100, 1, db, "raidFlatVerticalSpacing", UpdateFrames, function() DF:LightweightUpdateFrameSize() end, true), 55) + + Add(flatGridGroup, nil, 1) + + -- Update labels on show + if groupsPerRowSlider and groupsPerRowSlider.label then + groupsPerRowSlider:HookScript("OnShow", UpdateDynamicLabels) + end + if rowColSpacingSlider and rowColSpacingSlider.label then + rowColSpacingSlider:HookScript("OnShow", UpdateDynamicLabels) + end + if playersPerRowSlider and playersPerRowSlider.label then + playersPerRowSlider:HookScript("OnShow", UpdateDynamicLabels) + end + + -- ===== PERMANENT MOVER GROUP (Column 2) ===== + local permMoverGroup = GUI:CreateSettingsGroup(self.child, 280) + permMoverGroup:AddWidget(GUI:CreateHeader(self.child, L["Permanent Mover"]), 40) + + permMoverGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Enable Permanent Mover"], db, "permanentMover", function() + DF:UpdatePermanentMoverVisibility() + end), 30) + + local moverAnchorValues = { + TOPLEFT= L["Top Left"], TOP= L["Top"], TOPRIGHT= L["Top Right"], + LEFT= L["Left"], RIGHT= L["Right"], + BOTTOMLEFT= L["Bottom Left"], BOTTOM= L["Bottom"], BOTTOMRIGHT= L["Bottom Right"], + } + local permMoverAnchor = permMoverGroup:AddWidget( + GUI:CreateDropdown(self.child, L["Handle Position"], moverAnchorValues, db, "permanentMoverAnchor", function() + DF:UpdatePermanentMoverAnchor(GUI.SelectedMode) + end), 55) + permMoverAnchor.disableOn = function(d) return not d.permanentMover end + + local attachValues = { CONTAINER= L["Container"], FIRST= L["First Unit"], LAST= L["Last Unit"] } + local permAttach = permMoverGroup:AddWidget( + GUI:CreateDropdown(self.child, L["Attach To"], attachValues, db, "permanentMoverAttachTo", function() + DF:UpdatePermanentMoverAnchor(GUI.SelectedMode) + end), 55) + permAttach.disableOn = function(d) return not d.permanentMover end + permAttach.tooltip = L["Attach the handle to the container, the first visible unit, or the last visible unit."] + + local function PermMoverAnchorUpdate() DF:UpdatePermanentMoverAnchor(GUI.SelectedMode) end + local function PermMoverSizeUpdate() DF:UpdatePermanentMoverSize(GUI.SelectedMode) end + + local permOffsetX = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset X"], -500, 500, 1, db, "permanentMoverOffsetX", PermMoverAnchorUpdate, PermMoverAnchorUpdate), 55) + permOffsetX.disableOn = function(d) return not d.permanentMover end + + local permOffsetY = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Offset Y"], -500, 500, 1, db, "permanentMoverOffsetY", PermMoverAnchorUpdate, PermMoverAnchorUpdate), 55) + permOffsetY.disableOn = function(d) return not d.permanentMover end + + local permWidth = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Handle Width"], 5, 500, 1, db, "permanentMoverWidth", PermMoverSizeUpdate, PermMoverSizeUpdate), 55) + permWidth.disableOn = function(d) return not d.permanentMover end + + local permHeight = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Handle Height"], 5, 500, 1, db, "permanentMoverHeight", PermMoverSizeUpdate, PermMoverSizeUpdate), 55) + permHeight.disableOn = function(d) return not d.permanentMover end + + local permHover = permMoverGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Show on Hover Only"], db, "permanentMoverShowOnHover", function() + DF:UpdatePermanentMoverVisibility() + end), 30) + permHover.disableOn = function(d) return not d.permanentMover end + permHover.tooltip = L["Handle is invisible until you hover over it. Fades in and out smoothly."] + + local permCombat = permMoverGroup:AddWidget(GUI:CreateCheckbox(self.child, L["Hide in Combat"], db, "permanentMoverHideInCombat", function() + DF:UpdatePermanentMoverCombatState() + end), 30) + permCombat.disableOn = function(d) return not d.permanentMover end + permCombat.tooltip = L["Hides the handle during combat. If disabled, the handle changes color to indicate it is locked."] + + local permColor = permMoverGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Handle Color"], db, "permanentMoverColor", false, function() + DF:UpdatePermanentMoverColor(GUI.SelectedMode) + end), 35) + permColor.disableOn = function(d) return not d.permanentMover end + + local permCombatColor = permMoverGroup:AddWidget(GUI:CreateColorPicker(self.child, L["Combat Color"], db, "permanentMoverCombatColor", false, nil), 35) + permCombatColor.disableOn = function(d) return not d.permanentMover end + permCombatColor.tooltip = L["Color shown when in combat to indicate the handle is locked."] + + -- Quick action dropdowns + local actionValues = {} + for id, data in pairs(DF.PERM_MOVER_ACTIONS) do + actionValues[id] = data.label + end + + local permActionLeft = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Left Click"], actionValues, db, "permanentMoverActionLeft"), 55) + permActionLeft.disableOn = function(d) return not d.permanentMover end + + local permActionRight = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Right Click"], actionValues, db, "permanentMoverActionRight"), 55) + permActionRight.disableOn = function(d) return not d.permanentMover end + + local permActionShiftLeft = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Shift+Left Click"], actionValues, db, "permanentMoverActionShiftLeft"), 55) + permActionShiftLeft.disableOn = function(d) return not d.permanentMover end + + local permActionShiftRight = permMoverGroup:AddWidget(GUI:CreateDropdown(self.child, L["Shift+Right Click"], actionValues, db, "permanentMoverActionShiftRight"), 55) + permActionShiftRight.disableOn = function(d) return not d.permanentMover end + + local permPullTimer = permMoverGroup:AddWidget(GUI:CreateSlider(self.child, L["Pull Timer Duration"], 3, 30, 1, db, "permanentMoverPullTimerDuration"), 55) + permPullTimer.disableOn = function(d) return not d.permanentMover end + permPullTimer.tooltip = L["Duration in seconds for the Pull Timer quick action."] + + Add(permMoverGroup, nil, 2) + + -- See Also links + AddSpace(GUI.Space.block, "both") + Add(GUI:CreateSeeAlso(self.child, { + {pageId = "general_sorting", label = L["Sorting"]}, + {pageId = "bars_health", label = L["Health Bar"]}, + -- LEGACY-TEXT-CLEANUP: legacy text page hidden; link removed + -- {pageId = "text_name", label = L["Name Text"]}, + }), 30, "both") + end) + + -- General > Global Fonts + DF._SetupGUIPagesPart2(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) +end \ No newline at end of file From 122a5eb97f3efaee9898ede7387115f60aeaf4a4 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 18:44:01 +0100 Subject: [PATCH 11/34] Add a resident TestMode shim so test mode can become load-on-demand Phase 4 stage 2. Inert right now: TestMode.lua still loads and overwrites every stub, so behaviour is unchanged. It exists so the folder can move to the companion addon without live rendering calling into nothing. Test mode is a settings-panel feature, but live code calls into it: docs/reorg-tools/lod_gate_check.py found 31 resident call sites across Frames/Bars, Create, Init, StatusIcons and Core -- 16 of them DF:GetTestUnitData, sitting inline in the live render path. Nearly all are unreachable while test mode is off, so in practice they would never fire. That is not good enough for a nil call: both crashes after the file splits were a call into something not loaded, and both were invisible to every static check. The shim removes the class of failure instead of relying on control flow to avoid it. Stubs are the honest inactive answer. GetTestUnitData returns nil -- the same answer the real function gives for an index with no test unit, and every caller already handles it. The other nine are void; no-op is exact. Written out one per line rather than looped over a name list: a loop hides the stubbed surface from grep and from tooling. lod_gate_check could not see the table-indexed assignment and kept reporting nine already-stubbed symbols as unguarded. lod_gate_check now also recognises the "if not DF.X then DF.X = noop end" form as a definition -- it previously only matched "function DF:X" and a line-leading "DF.X = function". Unguarded resident calls into the payload: 36 -> 7. The remaining seven are five DF:ToggleGUI (the load trigger, which stage 3 builds) and two that are guarded by an if-block wider than the checker's context window. --- DandersFrames.toc | 4 ++++ TestMode/Shim.lua | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 TestMode/Shim.lua diff --git a/DandersFrames.toc b/DandersFrames.toc index 16a421c4..bc7aa499 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -117,6 +117,10 @@ Frames\Position.lua Frames\Pets.lua # Test Mode +# Shim.lua stays resident when the rest of this folder becomes load-on-demand: +# live rendering calls into test mode, so the entry points must always exist. +# ☠ It MUST load before TestMode.lua, which overwrites every stub. +TestMode\Shim.lua TestMode\TestFramePool.lua TestMode\TestMode.lua diff --git a/TestMode/Shim.lua b/TestMode/Shim.lua new file mode 100644 index 00000000..76a0f12b --- /dev/null +++ b/TestMode/Shim.lua @@ -0,0 +1,51 @@ +local addonName, DF = ... + +-- ============================================================ +-- TEST MODE SHIM — always loaded +-- ============================================================ +-- Test mode is a settings-panel feature, so it belongs in the load-on-demand +-- payload. It cannot simply leave, though: live rendering calls into it. A +-- sweep of resident code (docs/reorg-tools/lod_gate_check.py) found 31 call +-- sites across Frames/Bars, Create, Init, StatusIcons and Core -- 16 of them +-- DF:GetTestUnitData, sitting inline in the live render path. +-- +-- Nearly all of those are unreachable while test mode is off, so in practice +-- they would never fire. "In practice" is not good enough for a nil call: the +-- two crashes after the file splits were both a call into something that had +-- not loaded, and both were invisible to every static check. This file removes +-- the class of failure rather than relying on control flow to avoid it. +-- +-- Each stub is the honest inactive answer, not a fake: +-- GetTestUnitData -> nil, meaning "no test data for this unit". Every caller +-- already handles that (`if testData then`), because it is the same answer +-- the real function gives for an index with no test unit. +-- everything else -> a no-op. All are void; their returns are early exits. +-- +-- ☠ ORDERING: this file must load BEFORE the real TestMode.lua, which then +-- overwrites every stub. The `if not` guards make that safe rather than +-- assumed -- if the order were ever inverted, a plain assignment would clobber +-- the real implementations with no-ops and test mode would silently do +-- nothing, which is exactly the kind of quiet failure this file exists to stop. + +-- Written out one by one rather than looped over a name list. A loop is +-- shorter, but it makes the stubbed surface invisible to grep and to tooling: +-- lod_gate_check.py could not see `DF[name] = noop` and went on reporting nine +-- of these as unguarded. An API surface should be readable without running +-- anything. + +local function noop() end + +-- Returns the test unit's data table, or nil when there is none. Every caller +-- already handles nil -- it is the same answer the real function gives for an +-- index with no test unit behind it. +if not DF.GetTestUnitData then DF.GetTestUnitData = function() return nil end end + +if not DF.ShowTestFrames then DF.ShowTestFrames = noop end +if not DF.HideTestFrames then DF.HideTestFrames = noop end +if not DF.RefreshTestFrames then DF.RefreshTestFrames = noop end +if not DF.UpdateTestFrame then DF.UpdateTestFrame = noop end +if not DF.UpdateTestStatusIcons then DF.UpdateTestStatusIcons = noop end +if not DF.UpdateRaidTestFrames then DF.UpdateRaidTestFrames = noop end +if not DF.HideRaidTestFrames then DF.HideRaidTestFrames = noop end +if not DF.StopTestAnimation then DF.StopTestAnimation = noop end +if not DF.TeardownTestModeEngines then DF.TeardownTestModeEngines = noop end From fa8aee87e70074ff46bdd5b9ae6a56bf464e30c9 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 18:52:18 +0100 Subject: [PATCH 12/34] Move the Aura Designer migrations to a resident file Phase 4 stage 2b, and a correctness fix as much as a reorg step. Nine published migrations plus their private helpers -- 595 lines, moved byte-identically -- lived in AuraDesigner/UI/Options.lua, which is bound for the load-on-demand payload. They rewrite SAVED DATA, so they are data layer, not UI. Leaving them there would not have crashed. It would have been worse: Core.lua:4201 runs them on ADDON_LOADED over every saved profile, and its own comment says "load order guarantees that file has registered DF.MigrateAuraDesignerIconBorderKeys by here". AuraDesigner/Factory.lua:3033-3041 runs five of them on the render path. Both call sites are nil-guarded, so with the editor unloaded they would have silently SKIPPED. A player who never opens the settings panel would keep unmigrated saved data while live frames rendered from it -- and it would have migrated later, on first panel open, masking the cause. Found via docs/reorg-tools/lod_gate_check.py, though not the way intended: it flagged the two Core.lua call sites as unguarded, which was a false positive (the guard is an if-block wider than its context window). Reading the surrounding code to dismiss that is what surfaced the real problem. GetAuraDesignerDB stays with the editor and now calls the seven it needs by their published DF names rather than as file locals -- same functions. Unguarded resident calls into the payload: 7 -> 5, all now DF:ToggleGUI, which is the load trigger stage 3 builds. The block is proven byte-identical against the parent commit: 595 lines moved, 0 changed. --- AuraDesigner/Migrations.lua | 622 ++++++++++++++++++++++++++++++++++++ AuraDesigner/UI/Options.lua | 612 +---------------------------------- DandersFrames.toc | 6 + 3 files changed, 638 insertions(+), 602 deletions(-) create mode 100644 AuraDesigner/Migrations.lua diff --git a/AuraDesigner/Migrations.lua b/AuraDesigner/Migrations.lua new file mode 100644 index 00000000..4c945d3e --- /dev/null +++ b/AuraDesigner/Migrations.lua @@ -0,0 +1,622 @@ +local addonName, DF = ... + +-- ============================================================ +-- AURA DESIGNER MIGRATIONS -- always loaded +-- ============================================================ +-- These rewrite SAVED DATA, so they are data layer, not UI. They lived in +-- AuraDesigner/UI/Options.lua for historical reasons, which becomes a bug the +-- moment the editor is load-on-demand: +-- +-- Core.lua runs them on ADDON_LOADED over every saved profile, and its own +-- comment says "load order guarantees that file has registered ... by here". +-- AuraDesigner/Factory.lua runs five of them on the render path. +-- Both call sites are nil-GUARDED -- so with the editor unloaded they would +-- not error, they would SILENTLY SKIP. A player who never opens the settings +-- panel would keep unmigrated saved data while live frames render from it. +-- +-- A crash would have been kinder. Keeping these resident removes the question. +-- ☠ New AD migrations belong HERE, and must be added to BOTH GetAuraDesignerDB +-- (the editor's resolver) and the Factory render runner -- see the note above +-- MigrateAbsoluteLevelsLazy about what a mismatch does. + +local pairs, ipairs, type = pairs, ipairs, type +local tinsert = table.insert +local floor = math.floor +-- Load-time constant: AuraAdapter.lua loads before this file (see the .toc). +local Adapter = DF.AuraDesigner.Adapter + +-- ============================================================ +-- HELPERS +-- ============================================================ + +local function MigrateToSpecScoped(adDB) + if not adDB then return end + + -- V1: migrate flat adDB.auras → spec-keyed adDB.auras + if not adDB._specScopedV1 then + if adDB.auras then + local isFlat = false + for _, val in pairs(adDB.auras) do + -- `border` counts too: an entry that only ever had its border + -- customised carries neither priority nor indicators, and reading it + -- as already-spec-scoped stamped _specScopedV1 without migrating — + -- those auras then rendered nothing, permanently. Core.lua's + -- equivalent probe has always included it; this copy had drifted. + if type(val) == "table" + and (val.priority ~= nil or val.indicators ~= nil or val.border ~= nil) then + isFlat = true + break + end + end + if isFlat then + local oldAuras = adDB.auras + local newAuras = {} + local auraToSpecs = {} + -- Build the name→specs map from the FULL trackable set, not the + -- curated TrackableAuras table alone: the real set is curated PLUS + -- the SpellDB class pool (Adapter:GetTrackableAuras). Using only the + -- curated half meant every pool-sourced aura the user had configured + -- matched nothing — and the wholesale `adDB.auras = newAuras` below + -- then deleted it, with no read-back and no log. + local AD = DF.AuraDesigner + local adapter = AD and AD.Adapter + if adapter and adapter.GetTrackableAuras and AD.SpecInfo then + for specKey in pairs(AD.SpecInfo) do + local ok, list = pcall(adapter.GetTrackableAuras, adapter, specKey) + if ok and type(list) == "table" then + for _, info in ipairs(list) do + if info.name then + if not auraToSpecs[info.name] then auraToSpecs[info.name] = {} end + tinsert(auraToSpecs[info.name], specKey) + end + end + end + end + end + -- Curated table as a fallback/supplement (also covers the case where + -- the adapter or the registry is not available at migration time). + local trackable = AD and AD.TrackableAuras + if trackable then + for specKey, auraList in pairs(trackable) do + for _, info in ipairs(auraList) do + local seen = auraToSpecs[info.name] + if not seen then + auraToSpecs[info.name] = { specKey } + else + local dup = false + for _, s in ipairs(seen) do + if s == specKey then dup = true; break end + end + if not dup then tinsert(seen, specKey) end + end + end + end + end + + local orphans, orphanCount = nil, 0 + for auraName, auraCfg in pairs(oldAuras) do + local specs = auraToSpecs[auraName] + if specs then + for _, specKey in ipairs(specs) do + if not newAuras[specKey] then newAuras[specKey] = {} end + newAuras[specKey][auraName] = DF:DeepCopy(auraCfg) + end + else + -- NEVER drop it. Park unmatched configs where they can be + -- recovered rather than replacing the table out from under + -- them (a SpellDB regen that renames a spell would otherwise + -- destroy that aura's config on the next load). + orphans = orphans or {} + orphans[auraName] = DF:DeepCopy(auraCfg) + orphanCount = orphanCount + 1 + end + end + if orphans then + adDB._unscopedAuras = orphans + if DF.DebugWarn then + DF:DebugWarn("AD", + "spec-scope migration: %d aura config(s) matched no spec; parked in _unscopedAuras", + orphanCount) + end + end + adDB.auras = newAuras + end + end + adDB._specScopedV1 = true + end + + -- V2: migrate flat adDB.layoutGroups array → spec-keyed + if not adDB._specScopedV2 then + if adDB.layoutGroups then + -- Detect flat array: first entry has numeric key and .id field + local isFlat = false + for k, v in pairs(adDB.layoutGroups) do + if type(k) == "number" and type(v) == "table" and v.id ~= nil then + isFlat = true + break + end + end + if isFlat then + local oldGroups = adDB.layoutGroups + local newGroups = {} + -- For each group, find which specs its member auras belong to + local auraToSpecs = {} + if adDB.auras then + for specKey, specAuras in pairs(adDB.auras) do + if type(specAuras) == "table" then + for auraName in pairs(specAuras) do + if not auraToSpecs[auraName] then auraToSpecs[auraName] = {} end + auraToSpecs[auraName][specKey] = true + end + end + end + end + for _, group in ipairs(oldGroups) do + -- Determine which specs this group's members belong to + local targetSpecs = {} + if group.members then + for _, member in ipairs(group.members) do + local specs = auraToSpecs[member.auraName] + if specs then + for specKey in pairs(specs) do + targetSpecs[specKey] = true + end + end + end + end + -- Copy group to each relevant spec, filtering members + for specKey in pairs(targetSpecs) do + if not newGroups[specKey] then newGroups[specKey] = {} end + local groupCopy = DF:DeepCopy(group) + -- Filter members to only those that exist in this spec + if groupCopy.members then + local filtered = {} + for _, member in ipairs(groupCopy.members) do + local specs = auraToSpecs[member.auraName] + if specs and specs[specKey] then + tinsert(filtered, member) + end + end + groupCopy.members = filtered + end + tinsert(newGroups[specKey], groupCopy) + end + end + adDB.layoutGroups = newGroups + end + end + -- Migrate nextLayoutGroupID to per-spec too (just keep global as fallback) + adDB._specScopedV2 = true + end +end + +-- Expose for Engine.lua and post-import use +DF.MigrateAuraDesignerSpecScope = MigrateToSpecScoped + +-- ============================================================ +-- STAGE 5.1b — ICON BORDER KEY MIGRATION +-- Renames the legacy per-aura icon border keys to the canonical +-- CreateBorderControls naming (matches every other unified-border +-- consumer in the addon): +-- borderEnabled → ShowBorder +-- borderThickness → BorderSize +-- borderInset → BorderInset (case-only rename) +-- +-- Walks every aura × every storage shape (typeKey-keyed sub-config +-- and the newer indicators[] array) for every spec. Idempotent — +-- only renames when the new key is nil, so a partially-migrated +-- config is safe. +-- +-- Scope: icon (Stage 5.1) + square (Stage 5.2). Bar (Stage 5.3) still +-- reuses some of the same legacy key names (borderThickness, borderInset), +-- so we stay type-scoped — only rename an instance's keys once that +-- indicator type has migrated to the unified backend. The icon and square +-- differ only in the enable key: icon used `borderEnabled`, square used +-- `showBorder`; both fold to canonical `ShowBorder`. +-- ============================================================ + +-- Backfill canonical ONLY when absent, but ALWAYS strip the legacy key — even +-- when the canonical key already exists. A block edited in the new GUI writes +-- canonical keys but leaves the legacy ones behind; the old "swap-and-nil only +-- if canonical == nil" form then skipped the strip, leaving the legacy key to +-- linger forever (the half-migrated state). Mirrors renameBorderTypeKeys. +local function renameIconBorderKeys(t) + if type(t) ~= "table" then return end + if t.borderEnabled ~= nil then + if t.ShowBorder == nil then t.ShowBorder = t.borderEnabled end + t.borderEnabled = nil + end + if t.borderThickness ~= nil then + if t.BorderSize == nil then t.BorderSize = t.borderThickness end + t.borderThickness = nil + end + if t.borderInset ~= nil then + if t.BorderInset == nil then t.BorderInset = t.borderInset end + t.borderInset = nil + end +end + +-- Square (Stage 5.2): border-key renames ONLY. The square's enable key is +-- `showBorder` (vs the icon's `borderEnabled`). Deliberately does NOT touch +-- `expiringPulsate` — on the square that's the FILL pulse, a different effect +-- from the icon's border DF_PULSATE, so it stays a boolean. +local function renameSquareBorderKeys(t) + if type(t) ~= "table" then return end + -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. + if t.showBorder ~= nil then + if t.ShowBorder == nil then t.ShowBorder = t.showBorder end + t.showBorder = nil + end + if t.borderThickness ~= nil then + if t.BorderSize == nil then t.BorderSize = t.borderThickness end + t.borderThickness = nil + end + if t.borderInset ~= nil then + if t.BorderInset == nil then t.BorderInset = t.borderInset end + t.borderInset = nil + end +end + +-- Bar (Stage 5.3): border-key renames. Enable key is `showBorder` (like the +-- square); the bar also carries a static `borderColor` table → canonical +-- `BorderColor`. The bar has no legacy inset key. +local function renameBarBorderKeys(t) + if type(t) ~= "table" then return end + -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. + if t.showBorder ~= nil then + if t.ShowBorder == nil then t.ShowBorder = t.showBorder end + t.showBorder = nil + end + if t.borderThickness ~= nil then + if t.BorderSize == nil then t.BorderSize = t.borderThickness end + t.borderThickness = nil + end + if t.borderColor ~= nil then + if t.BorderColor == nil then t.BorderColor = t.borderColor end + t.borderColor = nil + end +end + +-- Border-type indicator (Stage 5.4): its legacy `style` enum maps onto a +-- DF.Border style + animation combo. One-way, lossy (the 5 styles become +-- canonical Style/Animation combinations). Gated on `style` being present so +-- it runs once. +local function renameBorderTypeKeys(t) + if type(t) ~= "table" then return end + -- The legacy `style` key is what forces the old render path (Indicators.lua's + -- `config.style and BuildBorderTypeSpec(...)`), so its presence is what we key + -- on. Crucially we run EVEN WHEN BorderStyle already exists: a block edited in + -- the new GUI writes canonical keys but leaves `style` behind, so it must still + -- get stripped or it keeps rendering via the legacy builder (the half-migrated + -- bug — legacy color/thickness shadowing the GUI's BorderColor/BorderSize). + if t.style == nil then return end + local thickness = t.thickness or 2 + local inset = t.inset or 0 + local color = t.color or { r = 0, g = 0, b = 0, a = 1 } + local legacy = { Solid = "SOLID", Glow = "GLOW", Pulse = "SOLID" } + local style = legacy[t.style] or t.style or "SOLID" + -- Fold legacy → canonical ONLY where the canonical key is absent, so we never + -- overwrite edits the user already made in the new GUI (BorderColor/BorderSize). + if t.ShowBorder == nil then t.ShowBorder = true end + if t.BorderInset == nil then t.BorderInset = inset end + if t.BorderColor == nil then t.BorderColor = color end + if t.BorderStyle == nil then + if style == "GLOW" then + t.BorderStyle = "TEXTURE" + if t.BorderTexture == nil then t.BorderTexture = "DF Glow" end + if t.BorderSize == nil then t.BorderSize = thickness end + elseif style == "DASHED" or style == "ANIMATED" then + -- The legacy dashed/animated styles drew a size-0 border made visible + -- ONLY by a container border animation, which 12.1 retired (aura buttons + -- go forbidden while auras are secret). Fold them to a visible static + -- border so an upgraded config renders something rather than nothing. + t.BorderStyle = "SOLID" + if t.BorderSize == nil then t.BorderSize = thickness end + elseif style == "CORNERS" then + t.BorderStyle = "SOLID" + if t.BorderSize == nil then t.BorderSize = 0 end + if t.BorderAnimationType == nil then + t.BorderAnimationType = "CORNERS_ONLY" + t.BorderAnimationThickness = thickness + t.BorderAnimationColor = color + end + else -- SOLID (and anything unknown) + t.BorderStyle = "SOLID" + if t.BorderSize == nil then t.BorderSize = thickness end + end + elseif t.BorderSize == nil then + -- BorderStyle already set (by the GUI); just backfill size from legacy. + t.BorderSize = thickness + end + -- Strip legacy keys so rendering routes through DF.Border:BuildSpec. + t.style = nil; t.thickness = nil; t.inset = nil; t.color = nil +end + +local function MigrateIconBorderKeysOnAuras(specAuras) + if type(specAuras) ~= "table" then return end + for _, auraCfg in pairs(specAuras) do + if type(auraCfg) == "table" then + -- Old shape: auraCfg. sub-config. + if auraCfg.icon then renameIconBorderKeys(auraCfg.icon) end + if auraCfg.square then renameSquareBorderKeys(auraCfg.square) end + if auraCfg.bar then renameBarBorderKeys(auraCfg.bar) end + if auraCfg.border then renameBorderTypeKeys(auraCfg.border) end + -- New shape: auraCfg.indicators[i] — each instance carries its + -- own border keys when the user has overridden defaults. + if auraCfg.indicators then + for _, ind in ipairs(auraCfg.indicators) do + if ind.type == "icon" then + renameIconBorderKeys(ind) + elseif ind.type == "square" then + renameSquareBorderKeys(ind) + elseif ind.type == "bar" then + renameBarBorderKeys(ind) + elseif ind.type == "border" then + renameBorderTypeKeys(ind) + end + end + end + end + end +end + +-- Run the icon/square/bar/border key renames over one `.auras` table, handling +-- both the flat ({ auraName → auraCfg }) and spec-scoped +-- ({ specKey → { auraName → auraCfg } }) shapes. Mirrors +-- MigrateAuraDesignerToInstances' shape detection so we stay correct whether the +-- spec-scope migration ran before us or not. +local function MigrateBorderKeysOnAurasTable(auras) + if type(auras) ~= "table" then return end + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + MigrateIconBorderKeysOnAuras(auras) -- flat + else + for _, specAuras in pairs(auras) do -- spec-scoped + MigrateIconBorderKeysOnAuras(specAuras) + end + end + end + break -- Only check first entry for shape detection + end +end + +local function MigrateAuraDesignerIconBorderKeys(modeDb) + local adDB = modeDb and modeDb.auraDesigner + if adDB and adDB.auras then + MigrateBorderKeysOnAurasTable(adDB.auras) + end +end + +-- The Designer Presets rework relocated AD aura configs into +-- profile.auraDesignerPresets[name].auras. MigrateAuraDesignerIconBorderKeys +-- only walks the legacy per-mode modeDb.auraDesigner location, so preset-nested +-- border blocks were never folded — they kept their legacy `style` (rendering via +-- the old builder) while the GUI wrote canonical keys onto them (the half-migrated +-- state). Walk every preset's auras so those blocks get folded + `style` stripped. +local function MigrateAuraDesignerPresetBorderKeys(profile) + local presets = profile and profile.auraDesignerPresets + if type(presets) ~= "table" then return end + for _, preset in pairs(presets) do + if type(preset) == "table" then + MigrateBorderKeysOnAurasTable(preset.auras) + end + end +end + +DF.MigrateAuraDesignerIconBorderKeys = MigrateAuraDesignerIconBorderKeys +DF.MigrateAuraDesignerPresetBorderKeys = MigrateAuraDesignerPresetBorderKeys + +-- Lazy, flag-gated border-key fold. This is the one that actually matters for +-- live frames: the ADDON_LOADED passes above clean the STORED tables (modeDb +-- auraDesigner + presets), but render resolves its config through +-- DF:ResolveAuraDesigner / GetModeBaseAuraDesigner, which can hand back a +-- resolved / auto-layout-overlay table the load-time passes never touched. +-- Mirroring MigrateToSpecScoped's lazy-on-access pattern, we fold the border keys +-- on the EXACT adDB about to be rendered/edited, gated by `_borderKeysFoldedV1` +-- so it runs once per table. (Idempotent if the table is rebuilt each access.) +local function MigrateBorderKeysLazy(adDB) + if type(adDB) ~= "table" or adDB._borderKeysFoldedV1 then return end + if adDB.auras then MigrateBorderKeysOnAurasTable(adDB.auras) end + adDB._borderKeysFoldedV1 = true +end +DF.MigrateAuraDesignerBorderKeysLazy = MigrateBorderKeysLazy + +-- Same lazy-on-access pattern for the type-keyed → instances[] migration. It +-- only ran at ADDON_LOADED on modeDb.auraDesigner, so an imported preset still in +-- the legacy type-keyed shape (icon/square/bar sub-tables, no `indicators`) would +-- render NOTHING — the engine reads `auraCfg.indicators`. Reuses the existing, +-- idempotent DF.MigrateAuraDesignerToInstances (guarded per-aura by `not +-- auraCfg.indicators`) via a thin modeDb wrapper, so there's no duplicated +-- conversion logic. The resolved adDB IS the preset by reference, so folding it +-- also cleans the stored data. Runs before the border fold to match load order. +local function MigrateInstancesLazy(adDB) + if type(adDB) ~= "table" or adDB._instancesFoldedV1 then return end + if DF.MigrateAuraDesignerToInstances and adDB.auras then + DF.MigrateAuraDesignerToInstances({ auraDesigner = adDB }) + end + adDB._instancesFoldedV1 = true +end +DF.MigrateAuraDesignerInstancesLazy = MigrateInstancesLazy + +-- Lazy, flag-gated Priority-scale flip (old lower-wins → new higher-wins). Same +-- resolve-time, once-per-table pattern as the border-key fold above: it runs on +-- the EXACT adDB about to be rendered/edited, so auto-layout overlays, presets, +-- and the legacy inline config are all covered AT POINT OF USE, gated by +-- `_priorityHigherWinsV1`. Fresh configs are born flagged (NewAuraDesignerConfig) +-- and exports/imports carry the flag on the copied table, so only genuine pre-4.6 +-- data is ever flipped — and exactly once. Replaces the old one-shot ADDON_LOADED +-- walker, which misclassified flat auras with no indicators and double-flipped +-- newly-created / imported profiles into corruption. +local function FlipAuraPriority(auraCfg) + if type(auraCfg) == "table" and type(auraCfg.priority) == "number" then + local p = math.floor(auraCfg.priority + 0.5) + if p < 1 then p = 1 elseif p > 10 then p = 10 end + auraCfg.priority = 11 - p + end +end +local function MigratePrioritiesLazy(adDB) + if type(adDB) ~= "table" or adDB._priorityHigherWinsV1 then return end + local auras = adDB.auras + if type(auras) == "table" then + -- Shape detection off the first entry only, matching MigrateBorderKeysOnAurasTable. + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + for _, auraCfg in pairs(auras) do FlipAuraPriority(auraCfg) end -- flat: auras[name] + else + for _, specAuras in pairs(auras) do -- spec: auras[spec][name] + if type(specAuras) == "table" then + for _, auraCfg in pairs(specAuras) do FlipAuraPriority(auraCfg) end + end + end + end + end + break + end + end + adDB._priorityHigherWinsV1 = true +end +DF.MigrateAuraDesignerPrioritiesLazy = MigratePrioritiesLazy + +-- Per-indicator Frame Level became an ABSOLUTE offset from the unit frame (the render used +-- to add 40 to whatever was stored). Shifts every stored value by that same 40 so no +-- indicator moves. Runs on the RESOLVED adDB -- presets, auto-layout overlays and the legacy +-- inline config are all covered at point of use -- mirroring MigratePrioritiesLazy. +local function ShiftIndicatorLevels(auraCfg) + if type(auraCfg) ~= "table" then return end + local inds = auraCfg.indicators + if type(inds) ~= "table" then return end + for _, ind in pairs(inds) do + if type(ind) == "table" and tonumber(ind.frameLevel) then + ind.frameLevel = tonumber(ind.frameLevel) + 40 + end + end +end +local function MigrateAbsoluteLevelsLazy(adDB) + if type(adDB) ~= "table" or adDB._absoluteFrameLevelV1 then return end + local auras = adDB.auras + if type(auras) == "table" then + -- Shape detection off the first entry only, matching MigratePrioritiesLazy. + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + for _, auraCfg in pairs(auras) do ShiftIndicatorLevels(auraCfg) end + else + for _, specAuras in pairs(auras) do + if type(specAuras) == "table" then + for _, auraCfg in pairs(specAuras) do ShiftIndicatorLevels(auraCfg) end + end + end + end + end + break + end + end + adDB._absoluteFrameLevelV1 = true +end + +-- V2 — undo the two places V1 got AD's level wrong. Read this before touching either. +-- +-- AD's real level has always been 40 (the buff-icon band). Commit 47075137 then added +-- `indicatorFrameLevel = 30` as a Config default while the render still computed +-- `40 + stored`, so 30 was written into a field that meant a NUDGE — it reads like the +-- status-icon convention (30) copied into the wrong units. Effect: 40 + 30 = 70. +-- +-- V1 above then made levels absolute and added the base back, so a spurious nudge of 30 +-- became a stored 70. Correct logic, bad input: it preserved the mistake permanently, and +-- 70 sits ABOVE the defensive icon (65) — which is meant to be the top layer. +-- +-- V1 also only walked auraCfg.indicators. It never shifted adDB.defaults.indicatorFrameLevel, +-- so that key was left holding the pre-absolute 30 while every per-indicator value moved to +-- the absolute scale. The two numbers have been in different units ever since, which is why +-- the Default Frame Level slider reads 30 on a profile whose indicators all render at 70. +-- +-- So V2 restores the baseline: +-- defaults.indicatorFrameLevel == 30 -> 40 +-- per-indicator frameLevel == 70 -> 40 +-- +-- ⚠ Both rewrites are value-targeted, not blanket. 30 and 70 are the exact fingerprints of +-- the bad seed. A deliberate absolute 30 is not a thing anyone would choose (it puts AD +-- UNDER the buff/debuff rows at 40), and 70 is only reachable from the seeded nudge. The +-- accepted cost: someone who genuinely nudged +30 for a reason loses it and lands at the +-- baseline. That is judged the right trade while 12.1 is unshipped and the alpha population +-- is small — an untouched profile drawing AD over the defensive icon is the worse default. +-- Any OTHER stored level is left exactly as it is; those are real user choices. +local function ResetSeededIndicatorLevels(auraCfg) + if type(auraCfg) ~= "table" then return end + local inds = auraCfg.indicators + if type(inds) ~= "table" then return end + for _, ind in pairs(inds) do + if type(ind) == "table" and tonumber(ind.frameLevel) == 70 then + ind.frameLevel = 40 + end + end +end +local function MigrateAbsoluteLevelsV2Lazy(adDB) + if type(adDB) ~= "table" or adDB._absoluteFrameLevelV2 then return end + + local defs = adDB.defaults + if type(defs) == "table" and tonumber(defs.indicatorFrameLevel) == 30 then + defs.indicatorFrameLevel = 40 + end + + local auras = adDB.auras + if type(auras) == "table" then + -- Same shape detection as V1 (flat auraCfg vs spec-scoped), for the same reason. + for _, val in pairs(auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + for _, auraCfg in pairs(auras) do ResetSeededIndicatorLevels(auraCfg) end + else + for _, specAuras in pairs(auras) do + if type(specAuras) == "table" then + for _, auraCfg in pairs(specAuras) do ResetSeededIndicatorLevels(auraCfg) end + end + end + end + end + break + end + end + + adDB._absoluteFrameLevelV2 = true +end + +DF.MigrateAuraDesignerAbsoluteLevelsLazy = MigrateAbsoluteLevelsLazy +DF.MigrateAuraDesignerAbsoluteLevelsV2Lazy = MigrateAbsoluteLevelsV2Lazy + + +-- Lazy, flag-gated ONE-TIME refresh of the AD global text defaults to the Midnight +-- baseline: DF Roboto SemiBold + drop shadow, 1.2 duration scale, stack count seated +-- bottom-right (2,-2). Pre-12.1 the AD shipped Friz Quadrata / plain OUTLINE / 1.0 / +-- centred stacks — WoW-generic values nobody picks deliberately (same reasoning as the +-- settings-panel font flip, Core.lua's _settingsFontRobotoDefaultV1). Runs on the +-- RESOLVED adDB (presets, auto-layout overlays, and the legacy inline config all +-- covered at point of use), flipping ONLY where the value is still the EXACT old +-- default — so any explicit choice sticks, and placed indicators that already store +-- their own values are untouched. +-- durationColorByTime is DELIBERATELY not flipped: existing profiles keep their +-- duration colours (a stored OFF is as likely a choice as a leftover). New / reset +-- profiles pick up the new ON default via the Config factory, newly PLACED +-- indicators always stamp ON (EnsureTypeConfig, v4 parity), and the Factory render +-- resolves nil-instance indicators through adDB.defaults (resolveDefCBT) so the +-- stored value actually governs. +-- MUST be wired into BOTH this editor accessor AND the Factory render runner +-- (Factory.lua ~2270), or the editor shows the new values while live frames render the +-- old ones until a manual re-entry forces a rebuild. +local function MigrateDefaultRefreshLazy(adDB) + if type(adDB) ~= "table" or adDB._adDefaultRefreshV1 then return end + local d = adDB.defaults + if type(d) == "table" then + if d.durationFont == "Friz Quadrata TT" then d.durationFont = "DF Roboto SemiBold" end + if d.stackFont == "Friz Quadrata TT" then d.stackFont = "DF Roboto SemiBold" end + if d.durationOutline == "OUTLINE" then d.durationOutline = "SHADOW;OUTLINE" end + if d.stackOutline == "OUTLINE" then d.stackOutline = "SHADOW;OUTLINE" end + if d.durationScale == 1 then d.durationScale = 1.2 end -- 1.0 == 1 in Lua + if d.stackX == 0 then d.stackX = 2 end + if d.stackY == 0 then d.stackY = -2 end + end + adDB._adDefaultRefreshV1 = true +end +DF.MigrateAuraDesignerDefaultRefreshLazy = MigrateDefaultRefreshLazy \ No newline at end of file diff --git a/AuraDesigner/UI/Options.lua b/AuraDesigner/UI/Options.lua index 2aee7d2c..45c3ca5a 100644 --- a/AuraDesigner/UI/Options.lua +++ b/AuraDesigner/UI/Options.lua @@ -133,601 +133,6 @@ end RefreshLocaleStrings() DF:RegisterLocaleRefresh(RefreshLocaleStrings) --- ============================================================ --- HELPERS --- ============================================================ - -local function MigrateToSpecScoped(adDB) - if not adDB then return end - - -- V1: migrate flat adDB.auras → spec-keyed adDB.auras - if not adDB._specScopedV1 then - if adDB.auras then - local isFlat = false - for _, val in pairs(adDB.auras) do - -- `border` counts too: an entry that only ever had its border - -- customised carries neither priority nor indicators, and reading it - -- as already-spec-scoped stamped _specScopedV1 without migrating — - -- those auras then rendered nothing, permanently. Core.lua's - -- equivalent probe has always included it; this copy had drifted. - if type(val) == "table" - and (val.priority ~= nil or val.indicators ~= nil or val.border ~= nil) then - isFlat = true - break - end - end - if isFlat then - local oldAuras = adDB.auras - local newAuras = {} - local auraToSpecs = {} - -- Build the name→specs map from the FULL trackable set, not the - -- curated TrackableAuras table alone: the real set is curated PLUS - -- the SpellDB class pool (Adapter:GetTrackableAuras). Using only the - -- curated half meant every pool-sourced aura the user had configured - -- matched nothing — and the wholesale `adDB.auras = newAuras` below - -- then deleted it, with no read-back and no log. - local AD = DF.AuraDesigner - local adapter = AD and AD.Adapter - if adapter and adapter.GetTrackableAuras and AD.SpecInfo then - for specKey in pairs(AD.SpecInfo) do - local ok, list = pcall(adapter.GetTrackableAuras, adapter, specKey) - if ok and type(list) == "table" then - for _, info in ipairs(list) do - if info.name then - if not auraToSpecs[info.name] then auraToSpecs[info.name] = {} end - tinsert(auraToSpecs[info.name], specKey) - end - end - end - end - end - -- Curated table as a fallback/supplement (also covers the case where - -- the adapter or the registry is not available at migration time). - local trackable = AD and AD.TrackableAuras - if trackable then - for specKey, auraList in pairs(trackable) do - for _, info in ipairs(auraList) do - local seen = auraToSpecs[info.name] - if not seen then - auraToSpecs[info.name] = { specKey } - else - local dup = false - for _, s in ipairs(seen) do - if s == specKey then dup = true; break end - end - if not dup then tinsert(seen, specKey) end - end - end - end - end - - local orphans, orphanCount = nil, 0 - for auraName, auraCfg in pairs(oldAuras) do - local specs = auraToSpecs[auraName] - if specs then - for _, specKey in ipairs(specs) do - if not newAuras[specKey] then newAuras[specKey] = {} end - newAuras[specKey][auraName] = DF:DeepCopy(auraCfg) - end - else - -- NEVER drop it. Park unmatched configs where they can be - -- recovered rather than replacing the table out from under - -- them (a SpellDB regen that renames a spell would otherwise - -- destroy that aura's config on the next load). - orphans = orphans or {} - orphans[auraName] = DF:DeepCopy(auraCfg) - orphanCount = orphanCount + 1 - end - end - if orphans then - adDB._unscopedAuras = orphans - if DF.DebugWarn then - DF:DebugWarn("AD", - "spec-scope migration: %d aura config(s) matched no spec; parked in _unscopedAuras", - orphanCount) - end - end - adDB.auras = newAuras - end - end - adDB._specScopedV1 = true - end - - -- V2: migrate flat adDB.layoutGroups array → spec-keyed - if not adDB._specScopedV2 then - if adDB.layoutGroups then - -- Detect flat array: first entry has numeric key and .id field - local isFlat = false - for k, v in pairs(adDB.layoutGroups) do - if type(k) == "number" and type(v) == "table" and v.id ~= nil then - isFlat = true - break - end - end - if isFlat then - local oldGroups = adDB.layoutGroups - local newGroups = {} - -- For each group, find which specs its member auras belong to - local auraToSpecs = {} - if adDB.auras then - for specKey, specAuras in pairs(adDB.auras) do - if type(specAuras) == "table" then - for auraName in pairs(specAuras) do - if not auraToSpecs[auraName] then auraToSpecs[auraName] = {} end - auraToSpecs[auraName][specKey] = true - end - end - end - end - for _, group in ipairs(oldGroups) do - -- Determine which specs this group's members belong to - local targetSpecs = {} - if group.members then - for _, member in ipairs(group.members) do - local specs = auraToSpecs[member.auraName] - if specs then - for specKey in pairs(specs) do - targetSpecs[specKey] = true - end - end - end - end - -- Copy group to each relevant spec, filtering members - for specKey in pairs(targetSpecs) do - if not newGroups[specKey] then newGroups[specKey] = {} end - local groupCopy = DF:DeepCopy(group) - -- Filter members to only those that exist in this spec - if groupCopy.members then - local filtered = {} - for _, member in ipairs(groupCopy.members) do - local specs = auraToSpecs[member.auraName] - if specs and specs[specKey] then - tinsert(filtered, member) - end - end - groupCopy.members = filtered - end - tinsert(newGroups[specKey], groupCopy) - end - end - adDB.layoutGroups = newGroups - end - end - -- Migrate nextLayoutGroupID to per-spec too (just keep global as fallback) - adDB._specScopedV2 = true - end -end - --- Expose for Engine.lua and post-import use -DF.MigrateAuraDesignerSpecScope = MigrateToSpecScoped - --- ============================================================ --- STAGE 5.1b — ICON BORDER KEY MIGRATION --- Renames the legacy per-aura icon border keys to the canonical --- CreateBorderControls naming (matches every other unified-border --- consumer in the addon): --- borderEnabled → ShowBorder --- borderThickness → BorderSize --- borderInset → BorderInset (case-only rename) --- --- Walks every aura × every storage shape (typeKey-keyed sub-config --- and the newer indicators[] array) for every spec. Idempotent — --- only renames when the new key is nil, so a partially-migrated --- config is safe. --- --- Scope: icon (Stage 5.1) + square (Stage 5.2). Bar (Stage 5.3) still --- reuses some of the same legacy key names (borderThickness, borderInset), --- so we stay type-scoped — only rename an instance's keys once that --- indicator type has migrated to the unified backend. The icon and square --- differ only in the enable key: icon used `borderEnabled`, square used --- `showBorder`; both fold to canonical `ShowBorder`. --- ============================================================ - --- Backfill canonical ONLY when absent, but ALWAYS strip the legacy key — even --- when the canonical key already exists. A block edited in the new GUI writes --- canonical keys but leaves the legacy ones behind; the old "swap-and-nil only --- if canonical == nil" form then skipped the strip, leaving the legacy key to --- linger forever (the half-migrated state). Mirrors renameBorderTypeKeys. -local function renameIconBorderKeys(t) - if type(t) ~= "table" then return end - if t.borderEnabled ~= nil then - if t.ShowBorder == nil then t.ShowBorder = t.borderEnabled end - t.borderEnabled = nil - end - if t.borderThickness ~= nil then - if t.BorderSize == nil then t.BorderSize = t.borderThickness end - t.borderThickness = nil - end - if t.borderInset ~= nil then - if t.BorderInset == nil then t.BorderInset = t.borderInset end - t.borderInset = nil - end -end - --- Square (Stage 5.2): border-key renames ONLY. The square's enable key is --- `showBorder` (vs the icon's `borderEnabled`). Deliberately does NOT touch --- `expiringPulsate` — on the square that's the FILL pulse, a different effect --- from the icon's border DF_PULSATE, so it stays a boolean. -local function renameSquareBorderKeys(t) - if type(t) ~= "table" then return end - -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. - if t.showBorder ~= nil then - if t.ShowBorder == nil then t.ShowBorder = t.showBorder end - t.showBorder = nil - end - if t.borderThickness ~= nil then - if t.BorderSize == nil then t.BorderSize = t.borderThickness end - t.borderThickness = nil - end - if t.borderInset ~= nil then - if t.BorderInset == nil then t.BorderInset = t.borderInset end - t.borderInset = nil - end -end - --- Bar (Stage 5.3): border-key renames. Enable key is `showBorder` (like the --- square); the bar also carries a static `borderColor` table → canonical --- `BorderColor`. The bar has no legacy inset key. -local function renameBarBorderKeys(t) - if type(t) ~= "table" then return end - -- Always strip legacy (see renameIconBorderKeys); backfill canonical if absent. - if t.showBorder ~= nil then - if t.ShowBorder == nil then t.ShowBorder = t.showBorder end - t.showBorder = nil - end - if t.borderThickness ~= nil then - if t.BorderSize == nil then t.BorderSize = t.borderThickness end - t.borderThickness = nil - end - if t.borderColor ~= nil then - if t.BorderColor == nil then t.BorderColor = t.borderColor end - t.borderColor = nil - end -end - --- Border-type indicator (Stage 5.4): its legacy `style` enum maps onto a --- DF.Border style + animation combo. One-way, lossy (the 5 styles become --- canonical Style/Animation combinations). Gated on `style` being present so --- it runs once. -local function renameBorderTypeKeys(t) - if type(t) ~= "table" then return end - -- The legacy `style` key is what forces the old render path (Indicators.lua's - -- `config.style and BuildBorderTypeSpec(...)`), so its presence is what we key - -- on. Crucially we run EVEN WHEN BorderStyle already exists: a block edited in - -- the new GUI writes canonical keys but leaves `style` behind, so it must still - -- get stripped or it keeps rendering via the legacy builder (the half-migrated - -- bug — legacy color/thickness shadowing the GUI's BorderColor/BorderSize). - if t.style == nil then return end - local thickness = t.thickness or 2 - local inset = t.inset or 0 - local color = t.color or { r = 0, g = 0, b = 0, a = 1 } - local legacy = { Solid = "SOLID", Glow = "GLOW", Pulse = "SOLID" } - local style = legacy[t.style] or t.style or "SOLID" - -- Fold legacy → canonical ONLY where the canonical key is absent, so we never - -- overwrite edits the user already made in the new GUI (BorderColor/BorderSize). - if t.ShowBorder == nil then t.ShowBorder = true end - if t.BorderInset == nil then t.BorderInset = inset end - if t.BorderColor == nil then t.BorderColor = color end - if t.BorderStyle == nil then - if style == "GLOW" then - t.BorderStyle = "TEXTURE" - if t.BorderTexture == nil then t.BorderTexture = "DF Glow" end - if t.BorderSize == nil then t.BorderSize = thickness end - elseif style == "DASHED" or style == "ANIMATED" then - -- The legacy dashed/animated styles drew a size-0 border made visible - -- ONLY by a container border animation, which 12.1 retired (aura buttons - -- go forbidden while auras are secret). Fold them to a visible static - -- border so an upgraded config renders something rather than nothing. - t.BorderStyle = "SOLID" - if t.BorderSize == nil then t.BorderSize = thickness end - elseif style == "CORNERS" then - t.BorderStyle = "SOLID" - if t.BorderSize == nil then t.BorderSize = 0 end - if t.BorderAnimationType == nil then - t.BorderAnimationType = "CORNERS_ONLY" - t.BorderAnimationThickness = thickness - t.BorderAnimationColor = color - end - else -- SOLID (and anything unknown) - t.BorderStyle = "SOLID" - if t.BorderSize == nil then t.BorderSize = thickness end - end - elseif t.BorderSize == nil then - -- BorderStyle already set (by the GUI); just backfill size from legacy. - t.BorderSize = thickness - end - -- Strip legacy keys so rendering routes through DF.Border:BuildSpec. - t.style = nil; t.thickness = nil; t.inset = nil; t.color = nil -end - -local function MigrateIconBorderKeysOnAuras(specAuras) - if type(specAuras) ~= "table" then return end - for _, auraCfg in pairs(specAuras) do - if type(auraCfg) == "table" then - -- Old shape: auraCfg. sub-config. - if auraCfg.icon then renameIconBorderKeys(auraCfg.icon) end - if auraCfg.square then renameSquareBorderKeys(auraCfg.square) end - if auraCfg.bar then renameBarBorderKeys(auraCfg.bar) end - if auraCfg.border then renameBorderTypeKeys(auraCfg.border) end - -- New shape: auraCfg.indicators[i] — each instance carries its - -- own border keys when the user has overridden defaults. - if auraCfg.indicators then - for _, ind in ipairs(auraCfg.indicators) do - if ind.type == "icon" then - renameIconBorderKeys(ind) - elseif ind.type == "square" then - renameSquareBorderKeys(ind) - elseif ind.type == "bar" then - renameBarBorderKeys(ind) - elseif ind.type == "border" then - renameBorderTypeKeys(ind) - end - end - end - end - end -end - --- Run the icon/square/bar/border key renames over one `.auras` table, handling --- both the flat ({ auraName → auraCfg }) and spec-scoped --- ({ specKey → { auraName → auraCfg } }) shapes. Mirrors --- MigrateAuraDesignerToInstances' shape detection so we stay correct whether the --- spec-scope migration ran before us or not. -local function MigrateBorderKeysOnAurasTable(auras) - if type(auras) ~= "table" then return end - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - MigrateIconBorderKeysOnAuras(auras) -- flat - else - for _, specAuras in pairs(auras) do -- spec-scoped - MigrateIconBorderKeysOnAuras(specAuras) - end - end - end - break -- Only check first entry for shape detection - end -end - -local function MigrateAuraDesignerIconBorderKeys(modeDb) - local adDB = modeDb and modeDb.auraDesigner - if adDB and adDB.auras then - MigrateBorderKeysOnAurasTable(adDB.auras) - end -end - --- The Designer Presets rework relocated AD aura configs into --- profile.auraDesignerPresets[name].auras. MigrateAuraDesignerIconBorderKeys --- only walks the legacy per-mode modeDb.auraDesigner location, so preset-nested --- border blocks were never folded — they kept their legacy `style` (rendering via --- the old builder) while the GUI wrote canonical keys onto them (the half-migrated --- state). Walk every preset's auras so those blocks get folded + `style` stripped. -local function MigrateAuraDesignerPresetBorderKeys(profile) - local presets = profile and profile.auraDesignerPresets - if type(presets) ~= "table" then return end - for _, preset in pairs(presets) do - if type(preset) == "table" then - MigrateBorderKeysOnAurasTable(preset.auras) - end - end -end - -DF.MigrateAuraDesignerIconBorderKeys = MigrateAuraDesignerIconBorderKeys -DF.MigrateAuraDesignerPresetBorderKeys = MigrateAuraDesignerPresetBorderKeys - --- Lazy, flag-gated border-key fold. This is the one that actually matters for --- live frames: the ADDON_LOADED passes above clean the STORED tables (modeDb --- auraDesigner + presets), but render resolves its config through --- DF:ResolveAuraDesigner / GetModeBaseAuraDesigner, which can hand back a --- resolved / auto-layout-overlay table the load-time passes never touched. --- Mirroring MigrateToSpecScoped's lazy-on-access pattern, we fold the border keys --- on the EXACT adDB about to be rendered/edited, gated by `_borderKeysFoldedV1` --- so it runs once per table. (Idempotent if the table is rebuilt each access.) -local function MigrateBorderKeysLazy(adDB) - if type(adDB) ~= "table" or adDB._borderKeysFoldedV1 then return end - if adDB.auras then MigrateBorderKeysOnAurasTable(adDB.auras) end - adDB._borderKeysFoldedV1 = true -end -DF.MigrateAuraDesignerBorderKeysLazy = MigrateBorderKeysLazy - --- Same lazy-on-access pattern for the type-keyed → instances[] migration. It --- only ran at ADDON_LOADED on modeDb.auraDesigner, so an imported preset still in --- the legacy type-keyed shape (icon/square/bar sub-tables, no `indicators`) would --- render NOTHING — the engine reads `auraCfg.indicators`. Reuses the existing, --- idempotent DF.MigrateAuraDesignerToInstances (guarded per-aura by `not --- auraCfg.indicators`) via a thin modeDb wrapper, so there's no duplicated --- conversion logic. The resolved adDB IS the preset by reference, so folding it --- also cleans the stored data. Runs before the border fold to match load order. -local function MigrateInstancesLazy(adDB) - if type(adDB) ~= "table" or adDB._instancesFoldedV1 then return end - if DF.MigrateAuraDesignerToInstances and adDB.auras then - DF.MigrateAuraDesignerToInstances({ auraDesigner = adDB }) - end - adDB._instancesFoldedV1 = true -end -DF.MigrateAuraDesignerInstancesLazy = MigrateInstancesLazy - --- Lazy, flag-gated Priority-scale flip (old lower-wins → new higher-wins). Same --- resolve-time, once-per-table pattern as the border-key fold above: it runs on --- the EXACT adDB about to be rendered/edited, so auto-layout overlays, presets, --- and the legacy inline config are all covered AT POINT OF USE, gated by --- `_priorityHigherWinsV1`. Fresh configs are born flagged (NewAuraDesignerConfig) --- and exports/imports carry the flag on the copied table, so only genuine pre-4.6 --- data is ever flipped — and exactly once. Replaces the old one-shot ADDON_LOADED --- walker, which misclassified flat auras with no indicators and double-flipped --- newly-created / imported profiles into corruption. -local function FlipAuraPriority(auraCfg) - if type(auraCfg) == "table" and type(auraCfg.priority) == "number" then - local p = math.floor(auraCfg.priority + 0.5) - if p < 1 then p = 1 elseif p > 10 then p = 10 end - auraCfg.priority = 11 - p - end -end -local function MigratePrioritiesLazy(adDB) - if type(adDB) ~= "table" or adDB._priorityHigherWinsV1 then return end - local auras = adDB.auras - if type(auras) == "table" then - -- Shape detection off the first entry only, matching MigrateBorderKeysOnAurasTable. - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - for _, auraCfg in pairs(auras) do FlipAuraPriority(auraCfg) end -- flat: auras[name] - else - for _, specAuras in pairs(auras) do -- spec: auras[spec][name] - if type(specAuras) == "table" then - for _, auraCfg in pairs(specAuras) do FlipAuraPriority(auraCfg) end - end - end - end - end - break - end - end - adDB._priorityHigherWinsV1 = true -end -DF.MigrateAuraDesignerPrioritiesLazy = MigratePrioritiesLazy - --- Per-indicator Frame Level became an ABSOLUTE offset from the unit frame (the render used --- to add 40 to whatever was stored). Shifts every stored value by that same 40 so no --- indicator moves. Runs on the RESOLVED adDB -- presets, auto-layout overlays and the legacy --- inline config are all covered at point of use -- mirroring MigratePrioritiesLazy. -local function ShiftIndicatorLevels(auraCfg) - if type(auraCfg) ~= "table" then return end - local inds = auraCfg.indicators - if type(inds) ~= "table" then return end - for _, ind in pairs(inds) do - if type(ind) == "table" and tonumber(ind.frameLevel) then - ind.frameLevel = tonumber(ind.frameLevel) + 40 - end - end -end -local function MigrateAbsoluteLevelsLazy(adDB) - if type(adDB) ~= "table" or adDB._absoluteFrameLevelV1 then return end - local auras = adDB.auras - if type(auras) == "table" then - -- Shape detection off the first entry only, matching MigratePrioritiesLazy. - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - for _, auraCfg in pairs(auras) do ShiftIndicatorLevels(auraCfg) end - else - for _, specAuras in pairs(auras) do - if type(specAuras) == "table" then - for _, auraCfg in pairs(specAuras) do ShiftIndicatorLevels(auraCfg) end - end - end - end - end - break - end - end - adDB._absoluteFrameLevelV1 = true -end - --- V2 — undo the two places V1 got AD's level wrong. Read this before touching either. --- --- AD's real level has always been 40 (the buff-icon band). Commit 47075137 then added --- `indicatorFrameLevel = 30` as a Config default while the render still computed --- `40 + stored`, so 30 was written into a field that meant a NUDGE — it reads like the --- status-icon convention (30) copied into the wrong units. Effect: 40 + 30 = 70. --- --- V1 above then made levels absolute and added the base back, so a spurious nudge of 30 --- became a stored 70. Correct logic, bad input: it preserved the mistake permanently, and --- 70 sits ABOVE the defensive icon (65) — which is meant to be the top layer. --- --- V1 also only walked auraCfg.indicators. It never shifted adDB.defaults.indicatorFrameLevel, --- so that key was left holding the pre-absolute 30 while every per-indicator value moved to --- the absolute scale. The two numbers have been in different units ever since, which is why --- the Default Frame Level slider reads 30 on a profile whose indicators all render at 70. --- --- So V2 restores the baseline: --- defaults.indicatorFrameLevel == 30 -> 40 --- per-indicator frameLevel == 70 -> 40 --- --- ⚠ Both rewrites are value-targeted, not blanket. 30 and 70 are the exact fingerprints of --- the bad seed. A deliberate absolute 30 is not a thing anyone would choose (it puts AD --- UNDER the buff/debuff rows at 40), and 70 is only reachable from the seeded nudge. The --- accepted cost: someone who genuinely nudged +30 for a reason loses it and lands at the --- baseline. That is judged the right trade while 12.1 is unshipped and the alpha population --- is small — an untouched profile drawing AD over the defensive icon is the worse default. --- Any OTHER stored level is left exactly as it is; those are real user choices. -local function ResetSeededIndicatorLevels(auraCfg) - if type(auraCfg) ~= "table" then return end - local inds = auraCfg.indicators - if type(inds) ~= "table" then return end - for _, ind in pairs(inds) do - if type(ind) == "table" and tonumber(ind.frameLevel) == 70 then - ind.frameLevel = 40 - end - end -end -local function MigrateAbsoluteLevelsV2Lazy(adDB) - if type(adDB) ~= "table" or adDB._absoluteFrameLevelV2 then return end - - local defs = adDB.defaults - if type(defs) == "table" and tonumber(defs.indicatorFrameLevel) == 30 then - defs.indicatorFrameLevel = 40 - end - - local auras = adDB.auras - if type(auras) == "table" then - -- Same shape detection as V1 (flat auraCfg vs spec-scoped), for the same reason. - for _, val in pairs(auras) do - if type(val) == "table" then - if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then - for _, auraCfg in pairs(auras) do ResetSeededIndicatorLevels(auraCfg) end - else - for _, specAuras in pairs(auras) do - if type(specAuras) == "table" then - for _, auraCfg in pairs(specAuras) do ResetSeededIndicatorLevels(auraCfg) end - end - end - end - end - break - end - end - - adDB._absoluteFrameLevelV2 = true -end - -DF.MigrateAuraDesignerAbsoluteLevelsLazy = MigrateAbsoluteLevelsLazy -DF.MigrateAuraDesignerAbsoluteLevelsV2Lazy = MigrateAbsoluteLevelsV2Lazy - - --- Lazy, flag-gated ONE-TIME refresh of the AD global text defaults to the Midnight --- baseline: DF Roboto SemiBold + drop shadow, 1.2 duration scale, stack count seated --- bottom-right (2,-2). Pre-12.1 the AD shipped Friz Quadrata / plain OUTLINE / 1.0 / --- centred stacks — WoW-generic values nobody picks deliberately (same reasoning as the --- settings-panel font flip, Core.lua's _settingsFontRobotoDefaultV1). Runs on the --- RESOLVED adDB (presets, auto-layout overlays, and the legacy inline config all --- covered at point of use), flipping ONLY where the value is still the EXACT old --- default — so any explicit choice sticks, and placed indicators that already store --- their own values are untouched. --- durationColorByTime is DELIBERATELY not flipped: existing profiles keep their --- duration colours (a stored OFF is as likely a choice as a leftover). New / reset --- profiles pick up the new ON default via the Config factory, newly PLACED --- indicators always stamp ON (EnsureTypeConfig, v4 parity), and the Factory render --- resolves nil-instance indicators through adDB.defaults (resolveDefCBT) so the --- stored value actually governs. --- MUST be wired into BOTH this editor accessor AND the Factory render runner --- (Factory.lua ~2270), or the editor shows the new values while live frames render the --- old ones until a manual re-entry forces a rebuild. -local function MigrateDefaultRefreshLazy(adDB) - if type(adDB) ~= "table" or adDB._adDefaultRefreshV1 then return end - local d = adDB.defaults - if type(d) == "table" then - if d.durationFont == "Friz Quadrata TT" then d.durationFont = "DF Roboto SemiBold" end - if d.stackFont == "Friz Quadrata TT" then d.stackFont = "DF Roboto SemiBold" end - if d.durationOutline == "OUTLINE" then d.durationOutline = "SHADOW;OUTLINE" end - if d.stackOutline == "OUTLINE" then d.stackOutline = "SHADOW;OUTLINE" end - if d.durationScale == 1 then d.durationScale = 1.2 end -- 1.0 == 1 in Lua - if d.stackX == 0 then d.stackX = 2 end - if d.stackY == 0 then d.stackY = -2 end - end - adDB._adDefaultRefreshV1 = true -end -DF.MigrateAuraDesignerDefaultRefreshLazy = MigrateDefaultRefreshLazy local function GetAuraDesignerDB() -- The editor is mode-tabbed: it edits the preset the active mode uses @@ -742,19 +147,22 @@ local function GetAuraDesignerDB() end -- Pre-migration / very-early fallback to the legacy inline config. adDB = adDB or (S.db and S.db.auraDesigner) + -- The migrations now live in AuraDesigner/Migrations.lua, which stays + -- resident so saved data is migrated whether or not this editor is loaded. + -- Same functions, reached by their published names instead of file locals. if adDB and (not adDB._specScopedV1 or not adDB._specScopedV2) then - MigrateToSpecScoped(adDB) + DF.MigrateAuraDesignerSpecScope(adDB) end - MigrateInstancesLazy(adDB) - MigrateBorderKeysLazy(adDB) - MigratePrioritiesLazy(adDB) + DF.MigrateAuraDesignerInstancesLazy(adDB) + DF.MigrateAuraDesignerBorderKeysLazy(adDB) + DF.MigrateAuraDesignerPrioritiesLazy(adDB) -- MUST match the render-path list in Factory.lua exactly. If the editor resolves an adDB -- the render has not touched yet (a preset, or an auto-layout overlay not currently shown) -- and skips a migration, the editor shows UN-migrated values -- and anything saved from that -- state gets migrated a second time when the render finally runs. - MigrateAbsoluteLevelsLazy(adDB) - MigrateAbsoluteLevelsV2Lazy(adDB) - MigrateDefaultRefreshLazy(adDB) + DF.MigrateAuraDesignerAbsoluteLevelsLazy(adDB) + DF.MigrateAuraDesignerAbsoluteLevelsV2Lazy(adDB) + DF.MigrateAuraDesignerDefaultRefreshLazy(adDB) return adDB end P.GetAuraDesignerDB = GetAuraDesignerDB diff --git a/DandersFrames.toc b/DandersFrames.toc index bc7aa499..2102aa42 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -139,6 +139,12 @@ Features\Dispel.lua # Aura Designer AuraDesigner\Config.lua AuraDesigner\AuraAdapter.lua +# Migrations rewrite SAVED DATA, so they stay resident even once the editor +# below becomes load-on-demand. Core.lua runs them on ADDON_LOADED and +# Factory.lua on the render path -- both guarded, so an unloaded editor would +# not error, it would silently skip and leave saved data unmigrated. +# ☠ Must load after AuraAdapter.lua: it reads DF.AuraDesigner.Adapter at file scope. +AuraDesigner\Migrations.lua AuraDesigner\Engine.lua AuraDesigner\Factory.lua AuraDesigner\SoundEngine.lua From e01be0356d0ed3bf5b0094384e8025215b72d9f1 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 19:27:39 +0100 Subject: [PATCH 13/34] Restructure the repo as a container for two addon folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prep for the load-on-demand companion. WoW resolves LoadAddOn("Name") to AddOns/Name/Name.toc, so a companion has to be a top-level addon folder -- a sibling of DandersFrames, not a subfolder of it. That is the standard layout for a project shipping a load-on-demand options module, and it means the repo root can no longer BE the addon. / DandersFrames/ the addon (212 files moved, byte-identical) CHANGELOG.md README.md TRANSLATING.md .pkgmeta .github/ generate_changelog.sh Tools/ docs/ 215 files changed, 29 insertions, 6 deletions -- every .lua and .tga is a zero-change rename. The three real edits are path references that were relative to the repo root and are now one level out from the addon: generate_changelog.sh OUTPUT_FILE, TOC_FILE release.yml build-skip filter, version grep, channel grep That is the third time this pattern has appeared (Changelog.lua's move, the Core/ move, now this). Anything outside the addon folder that names a path inside it has to move with it. The verification tooling under docs/reorg-tools/ now defaults its root to /DandersFrames, so every checker still runs with no arguments. ☠ .pkgmeta is NOT fixed and is marked as such in the file. It still says package-as: DandersFrames, which would nest the addon one level too deep in the release zip. move-folders is the mechanism but I could not confirm its exact path semantics offline, and a wrong guess ships a zip that installs to the wrong place -- silently. The file carries the test procedure: push a throwaway tag, inspect the zip's top level, delete the tag. DO NOT CUT A RELEASE UNTIL THAT IS SETTLED. The addon does not load from this commit alone: AddOns/DandersFrames now needs to be a junction to /DandersFrames. See the follow-up commit for the dev setup. --- .github/workflows/release.yml | 6 ++--- .pkgmeta | 23 ++++++++++++++++++ .../AuraBlacklist}/Config.lua | 0 .../AuraDesigner}/AuraAdapter.lua | 0 .../AuraDesigner}/Config.lua | 0 .../AuraDesigner}/Engine.lua | 0 .../AuraDesigner}/Factory.lua | 0 .../AuraDesigner}/Migrations.lua | 0 .../AuraDesigner}/SoundEngine.lua | 0 .../AuraDesigner}/UI/Cards.lua | 0 .../AuraDesigner}/UI/Editor.lua | 0 .../AuraDesigner}/UI/Groups.lua | 0 .../AuraDesigner}/UI/Indicators.lua | 0 .../AuraDesigner}/UI/Options.lua | 0 .../ClickCasting}/Bindings.lua | 0 .../ClickCasting}/Constants.lua | 0 .../ClickCasting}/Core.lua | 0 .../ClickCasting}/Events.lua | 0 .../ClickCasting}/Frames.lua | 0 .../ClickCasting}/Profiles.lua | 0 .../ClickCasting}/UI/BindingEditor.lua | 0 .../ClickCasting}/UI/Dialogs.lua | 0 .../ClickCasting}/UI/Main.lua | 0 .../ClickCasting}/UI/ProfilesPanel.lua | 0 Core.lua => DandersFrames/Core.lua | 0 {Core => DandersFrames/Core}/API.lua | 0 {Core => DandersFrames/Core}/Changelog.lua | 0 {Core => DandersFrames/Core}/Config.lua | 0 .../Core}/ExportCategories.lua | 0 {Core => DandersFrames/Core}/Profile.lua | 0 {Core => DandersFrames/Core}/RosterEvents.lua | 0 .../DandersFrames.toc | 0 .../DandersFrames.xml | 0 .../Debug}/AtlasBrowser.lua | 0 .../Debug}/AttachmentScan.lua | 0 .../Debug}/AuraExplorer.lua | 0 .../Debug}/DebugConsole.lua | 0 {Debug => DandersFrames/Debug}/MemoryTest.lua | 0 {Debug => DandersFrames/Debug}/Profiler.lua | 0 .../Designer}/Presets.lua | 0 .../Features}/Auras.lua | 0 .../Features}/Dispel.lua | 0 .../Features}/ElementAppearance.lua | 0 .../Features}/Expiration.lua | 0 .../Features}/FlatRaidFrames.lua | 0 .../Features}/FrameSort.lua | 0 .../Features}/HealthFade.lua | 0 .../Features}/Highlights.lua | 0 .../Features}/Nicknames.lua | 0 .../Features}/NicknamesComm.lua | 0 .../Features}/PinnedFrames.lua | 0 .../Features}/Range.lua | 0 .../Features}/Search.lua | 0 .../Features}/SecureSort.lua | 0 {Features => DandersFrames/Features}/Sort.lua | 0 .../Features}/TargetedSpells.lua | 0 .../Features}/VersionCheck.lua | 0 .../FilterRegistry}/Registry.lua | 0 .../FilterRegistry}/SpellDB.lua | 0 .../FilterRegistry}/UI/Options.lua | 0 .../FilterRegistry}/UI/SpellPicker.lua | 0 {Fonts => DandersFrames/Fonts}/Expressway.ttf | Bin .../Fonts}/Roboto-Bold.ttf | Bin .../Fonts}/Roboto-SemiBold.ttf | Bin .../Fonts}/RobotoMono-Bold.ttf | Bin .../Fonts}/RobotoMono-SemiBold.ttf | Bin .../Frames}/AuraContainer.lua | 0 {Frames => DandersFrames/Frames}/Bars.lua | 0 {Frames => DandersFrames/Frames}/Border.lua | 0 {Frames => DandersFrames/Frames}/Colors.lua | 0 {Frames => DandersFrames/Frames}/Core.lua | 0 {Frames => DandersFrames/Frames}/Create.lua | 0 {Frames => DandersFrames/Frames}/Headers.lua | 0 {Frames => DandersFrames/Frames}/Icons.lua | 0 {Frames => DandersFrames/Frames}/Init.lua | 0 {Frames => DandersFrames/Frames}/Pets.lua | 0 {Frames => DandersFrames/Frames}/Position.lua | 0 .../Frames}/ReducedMaxHealth.lua | 0 .../Frames}/StatusIcons.lua | 0 .../Frames}/TextStyle.lua | 0 {Frames => DandersFrames/Frames}/Update.lua | 0 {GUI => DandersFrames/GUI}/ColorPicker.lua | 0 {GUI => DandersFrames/GUI}/Controls.lua | 0 {GUI => DandersFrames/GUI}/DFFonts.lua | 0 {GUI => DandersFrames/GUI}/GUI.lua | 0 {GUI => DandersFrames/GUI}/IconLib.lua | 0 {GUI => DandersFrames/GUI}/Pages/Auras.lua | 0 .../GUI}/Pages/AutoProfiles.lua | 0 {GUI => DandersFrames/GUI}/Pages/Frames.lua | 0 .../GUI}/Pages/Indicators.lua | 0 {GUI => DandersFrames/GUI}/Pages/Modules.lua | 0 .../GUI}/Pages/NicknamesPage.lua | 0 .../GUI}/Pages/NicknamesPicker.lua | 0 {GUI => DandersFrames/GUI}/Pages/Options.lua | 0 {GUI => DandersFrames/GUI}/Panel.lua | 0 {GUI => DandersFrames/GUI}/Popup.lua | 0 {GUI => DandersFrames/GUI}/Sections.lua | 0 {GUI => DandersFrames/GUI}/Widgets.lua | 0 .../Libs}/AceLocale-3.0/AceLocale-3.0.lua | 0 .../AceSerializer-3.0/AceSerializer-3.0.lua | 0 .../AceSerializer-3.0/AceSerializer-3.0.xml | 0 .../CallbackHandler-1.0.lua | 0 .../CallbackHandler-1.0.xml | 0 .../Libs}/LibDBIcon-1.0/LibDBIcon-1.0.lua | 0 .../Changelog-libdatabroker-1-1-v1.1.4.txt | 0 .../LibDataBroker-1.1/LibDataBroker-1.1.lua | 0 .../Libs}/LibDataBroker-1.1/README.textile | 0 .../Libs}/LibDeflate/LibDeflate.lua | 0 .../Libs}/LibDeflate/lib.xml | 0 .../Libs}/LibSerialize/LibSerialize.lua | 0 .../Libs}/LibSerialize/lib.xml | 0 .../LibSharedMedia-3.0/LibSharedMedia-3.0.lua | 0 .../LibSharedMedia-3.0/LibSharedMedia-3.0.xml | 0 .../Libs}/LibStub/LibStub.lua | 0 {Locales => DandersFrames/Locales}/deDE.lua | 0 {Locales => DandersFrames/Locales}/enUS.lua | 0 {Locales => DandersFrames/Locales}/esES.lua | 0 {Locales => DandersFrames/Locales}/esMX.lua | 0 {Locales => DandersFrames/Locales}/frFR.lua | 0 {Locales => DandersFrames/Locales}/itIT.lua | 0 {Locales => DandersFrames/Locales}/koKR.lua | 0 {Locales => DandersFrames/Locales}/ptBR.lua | 0 {Locales => DandersFrames/Locales}/ruRU.lua | 0 {Locales => DandersFrames/Locales}/zhCN.lua | 0 {Locales => DandersFrames/Locales}/zhTW.lua | 0 .../Media}/Classic_Curve.tga | Bin .../Media}/DF_AlertBadge.tga | Bin .../Media}/DF_AlertMark.tga | Bin {Media => DandersFrames/Media}/DF_Bevel.tga | Bin {Media => DandersFrames/Media}/DF_Beveled.tga | Bin .../Media}/DF_ColorWheel.tga | Bin .../Media}/DF_Curve_Smooth.tga | Bin .../Media}/DF_Curve_Stops.tga | Bin {Media => DandersFrames/Media}/DF_DPS.tga | Bin {Media => DandersFrames/Media}/DF_Double.tga | Bin .../Media}/DF_ExpireBorder.tga | Bin .../Media}/DF_ExpireBorder_Fill.tga | Bin .../Media}/DF_ExpireBorder_Thick.tga | Bin .../Media}/DF_ExpireBorder_Thin.tga | Bin {Media => DandersFrames/Media}/DF_Glass.tga | Bin {Media => DandersFrames/Media}/DF_Glossy.tga | Bin {Media => DandersFrames/Media}/DF_Glow.tga | Bin .../Media}/DF_Gradient_H.tga | Bin .../Media}/DF_Gradient_H_Rev.tga | Bin .../Media}/DF_Gradient_V.tga | Bin .../Media}/DF_Gradient_V_Rev.tga | Bin {Media => DandersFrames/Media}/DF_Healer.tga | Bin {Media => DandersFrames/Media}/DF_Icon.tga | Bin {Media => DandersFrames/Media}/DF_Inset.tga | Bin {Media => DandersFrames/Media}/DF_Matte.tga | Bin .../Media}/DF_Minimalist.tga | Bin {Media => DandersFrames/Media}/DF_Ring.tga | Bin {Media => DandersFrames/Media}/DF_Smooth.tga | Bin {Media => DandersFrames/Media}/DF_Soft.tga | Bin .../Media}/DF_SquareBorder.tga | Bin .../Media}/DF_SquareRing.tga | Bin {Media => DandersFrames/Media}/DF_Stripes.tga | Bin .../Media}/DF_Stripes_Dense.tga | Bin .../Media}/DF_Stripes_Medium.tga | Bin .../Media}/DF_Stripes_Soft.tga | Bin .../Media}/DF_Stripes_Soft_Wide.tga | Bin .../Media}/DF_Stripes_Sparse.tga | Bin .../Media}/DF_Stripes_Very_Dense.tga | Bin {Media => DandersFrames/Media}/DF_Tank.tga | Bin {Media => DandersFrames/Media}/Icons/add.tga | Bin .../Media}/Icons/check.tga | Bin .../Media}/Icons/chevron_right.tga | Bin .../Media}/Icons/close.tga | Bin .../Media}/Icons/content_copy.tga | Bin .../Media}/Icons/delete.tga | Bin {Media => DandersFrames/Media}/Icons/dot.tga | Bin .../Media}/Icons/download.tga | Bin {Media => DandersFrames/Media}/Icons/edit.tga | Bin .../Media}/Icons/edit_square.tga | Bin .../Media}/Icons/expand_more.tga | Bin .../Media}/Icons/filter_alt.tga | Bin .../Media}/Icons/filter_list.tga | Bin {Media => DandersFrames/Media}/Icons/info.tga | Bin .../Media}/Icons/keyboard.tga | Bin {Media => DandersFrames/Media}/Icons/lock.tga | Bin .../Media}/Icons/lock_open.tga | Bin {Media => DandersFrames/Media}/Icons/menu.tga | Bin .../Media}/Icons/mouse.tga | Bin .../Media}/Icons/notes.tga | Bin .../Media}/Icons/preview.tga | Bin .../Media}/Icons/preview_off.tga | Bin .../Media}/Icons/refresh.tga | Bin .../Media}/Icons/remove.tga | Bin .../Media}/Icons/reorder.tga | Bin {Media => DandersFrames/Media}/Icons/save.tga | Bin .../Media}/Icons/search.tga | Bin .../Media}/Icons/settings.tga | Bin {Media => DandersFrames/Media}/Icons/star.tga | Bin {Media => DandersFrames/Media}/Icons/sync.tga | Bin .../Media}/Icons/sync_disabled.tga | Bin .../Media}/Icons/upload.tga | Bin .../Media}/Icons/visibility.tga | Bin .../Media}/Icons/visibility_off.tga | Bin .../Media}/Icons/warning.tga | Bin .../Media}/Icons/widget_small.tga | Bin {TestMode => DandersFrames/TestMode}/Shim.lua | 0 .../TestMode}/TestFramePool.lua | 0 .../TestMode}/TestMode.lua | 0 .../TextDesigner}/DataSource.lua | 0 .../TextDesigner}/MidnightSafe.lua | 0 .../TextDesigner}/Migration.lua | 0 .../TextDesigner}/Preview.lua | 0 .../TextDesigner}/Render.lua | 0 .../TextDesigner}/Resolver.lua | 0 .../TextDesigner}/TextDesigner.lua | 0 .../TextDesigner}/UI/Options.lua | 0 .../Textures}/Wizards/overlay_disabled.tga | Bin .../Textures}/Wizards/overlay_enabled.tga | Bin .../Textures}/Wizards/overlay_warning.tga | Bin generate_changelog.sh | 6 ++--- 215 files changed, 29 insertions(+), 6 deletions(-) rename {AuraBlacklist => DandersFrames/AuraBlacklist}/Config.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/AuraAdapter.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/Config.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/Engine.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/Factory.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/Migrations.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/SoundEngine.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/UI/Cards.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/UI/Editor.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/UI/Groups.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/UI/Indicators.lua (100%) rename {AuraDesigner => DandersFrames/AuraDesigner}/UI/Options.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/Bindings.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/Constants.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/Core.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/Events.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/Frames.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/Profiles.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/UI/BindingEditor.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/UI/Dialogs.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/UI/Main.lua (100%) rename {ClickCasting => DandersFrames/ClickCasting}/UI/ProfilesPanel.lua (100%) rename Core.lua => DandersFrames/Core.lua (100%) rename {Core => DandersFrames/Core}/API.lua (100%) rename {Core => DandersFrames/Core}/Changelog.lua (100%) rename {Core => DandersFrames/Core}/Config.lua (100%) rename {Core => DandersFrames/Core}/ExportCategories.lua (100%) rename {Core => DandersFrames/Core}/Profile.lua (100%) rename {Core => DandersFrames/Core}/RosterEvents.lua (100%) rename DandersFrames.toc => DandersFrames/DandersFrames.toc (100%) rename DandersFrames.xml => DandersFrames/DandersFrames.xml (100%) rename {Debug => DandersFrames/Debug}/AtlasBrowser.lua (100%) rename {Debug => DandersFrames/Debug}/AttachmentScan.lua (100%) rename {Debug => DandersFrames/Debug}/AuraExplorer.lua (100%) rename {Debug => DandersFrames/Debug}/DebugConsole.lua (100%) rename {Debug => DandersFrames/Debug}/MemoryTest.lua (100%) rename {Debug => DandersFrames/Debug}/Profiler.lua (100%) rename {Designer => DandersFrames/Designer}/Presets.lua (100%) rename {Features => DandersFrames/Features}/Auras.lua (100%) rename {Features => DandersFrames/Features}/Dispel.lua (100%) rename {Features => DandersFrames/Features}/ElementAppearance.lua (100%) rename {Features => DandersFrames/Features}/Expiration.lua (100%) rename {Features => DandersFrames/Features}/FlatRaidFrames.lua (100%) rename {Features => DandersFrames/Features}/FrameSort.lua (100%) rename {Features => DandersFrames/Features}/HealthFade.lua (100%) rename {Features => DandersFrames/Features}/Highlights.lua (100%) rename {Features => DandersFrames/Features}/Nicknames.lua (100%) rename {Features => DandersFrames/Features}/NicknamesComm.lua (100%) rename {Features => DandersFrames/Features}/PinnedFrames.lua (100%) rename {Features => DandersFrames/Features}/Range.lua (100%) rename {Features => DandersFrames/Features}/Search.lua (100%) rename {Features => DandersFrames/Features}/SecureSort.lua (100%) rename {Features => DandersFrames/Features}/Sort.lua (100%) rename {Features => DandersFrames/Features}/TargetedSpells.lua (100%) rename {Features => DandersFrames/Features}/VersionCheck.lua (100%) rename {FilterRegistry => DandersFrames/FilterRegistry}/Registry.lua (100%) rename {FilterRegistry => DandersFrames/FilterRegistry}/SpellDB.lua (100%) rename {FilterRegistry => DandersFrames/FilterRegistry}/UI/Options.lua (100%) rename {FilterRegistry => DandersFrames/FilterRegistry}/UI/SpellPicker.lua (100%) rename {Fonts => DandersFrames/Fonts}/Expressway.ttf (100%) rename {Fonts => DandersFrames/Fonts}/Roboto-Bold.ttf (100%) rename {Fonts => DandersFrames/Fonts}/Roboto-SemiBold.ttf (100%) rename {Fonts => DandersFrames/Fonts}/RobotoMono-Bold.ttf (100%) rename {Fonts => DandersFrames/Fonts}/RobotoMono-SemiBold.ttf (100%) rename {Frames => DandersFrames/Frames}/AuraContainer.lua (100%) rename {Frames => DandersFrames/Frames}/Bars.lua (100%) rename {Frames => DandersFrames/Frames}/Border.lua (100%) rename {Frames => DandersFrames/Frames}/Colors.lua (100%) rename {Frames => DandersFrames/Frames}/Core.lua (100%) rename {Frames => DandersFrames/Frames}/Create.lua (100%) rename {Frames => DandersFrames/Frames}/Headers.lua (100%) rename {Frames => DandersFrames/Frames}/Icons.lua (100%) rename {Frames => DandersFrames/Frames}/Init.lua (100%) rename {Frames => DandersFrames/Frames}/Pets.lua (100%) rename {Frames => DandersFrames/Frames}/Position.lua (100%) rename {Frames => DandersFrames/Frames}/ReducedMaxHealth.lua (100%) rename {Frames => DandersFrames/Frames}/StatusIcons.lua (100%) rename {Frames => DandersFrames/Frames}/TextStyle.lua (100%) rename {Frames => DandersFrames/Frames}/Update.lua (100%) rename {GUI => DandersFrames/GUI}/ColorPicker.lua (100%) rename {GUI => DandersFrames/GUI}/Controls.lua (100%) rename {GUI => DandersFrames/GUI}/DFFonts.lua (100%) rename {GUI => DandersFrames/GUI}/GUI.lua (100%) rename {GUI => DandersFrames/GUI}/IconLib.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/Auras.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/AutoProfiles.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/Frames.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/Indicators.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/Modules.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/NicknamesPage.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/NicknamesPicker.lua (100%) rename {GUI => DandersFrames/GUI}/Pages/Options.lua (100%) rename {GUI => DandersFrames/GUI}/Panel.lua (100%) rename {GUI => DandersFrames/GUI}/Popup.lua (100%) rename {GUI => DandersFrames/GUI}/Sections.lua (100%) rename {GUI => DandersFrames/GUI}/Widgets.lua (100%) rename {Libs => DandersFrames/Libs}/AceLocale-3.0/AceLocale-3.0.lua (100%) rename {Libs => DandersFrames/Libs}/AceSerializer-3.0/AceSerializer-3.0.lua (100%) rename {Libs => DandersFrames/Libs}/AceSerializer-3.0/AceSerializer-3.0.xml (100%) rename {Libs => DandersFrames/Libs}/CallbackHandler-1.0/CallbackHandler-1.0.lua (100%) rename {Libs => DandersFrames/Libs}/CallbackHandler-1.0/CallbackHandler-1.0.xml (100%) rename {Libs => DandersFrames/Libs}/LibDBIcon-1.0/LibDBIcon-1.0.lua (100%) rename {Libs => DandersFrames/Libs}/LibDataBroker-1.1/Changelog-libdatabroker-1-1-v1.1.4.txt (100%) rename {Libs => DandersFrames/Libs}/LibDataBroker-1.1/LibDataBroker-1.1.lua (100%) rename {Libs => DandersFrames/Libs}/LibDataBroker-1.1/README.textile (100%) rename {Libs => DandersFrames/Libs}/LibDeflate/LibDeflate.lua (100%) rename {Libs => DandersFrames/Libs}/LibDeflate/lib.xml (100%) rename {Libs => DandersFrames/Libs}/LibSerialize/LibSerialize.lua (100%) rename {Libs => DandersFrames/Libs}/LibSerialize/lib.xml (100%) rename {Libs => DandersFrames/Libs}/LibSharedMedia-3.0/LibSharedMedia-3.0.lua (100%) rename {Libs => DandersFrames/Libs}/LibSharedMedia-3.0/LibSharedMedia-3.0.xml (100%) rename {Libs => DandersFrames/Libs}/LibStub/LibStub.lua (100%) rename {Locales => DandersFrames/Locales}/deDE.lua (100%) rename {Locales => DandersFrames/Locales}/enUS.lua (100%) rename {Locales => DandersFrames/Locales}/esES.lua (100%) rename {Locales => DandersFrames/Locales}/esMX.lua (100%) rename {Locales => DandersFrames/Locales}/frFR.lua (100%) rename {Locales => DandersFrames/Locales}/itIT.lua (100%) rename {Locales => DandersFrames/Locales}/koKR.lua (100%) rename {Locales => DandersFrames/Locales}/ptBR.lua (100%) rename {Locales => DandersFrames/Locales}/ruRU.lua (100%) rename {Locales => DandersFrames/Locales}/zhCN.lua (100%) rename {Locales => DandersFrames/Locales}/zhTW.lua (100%) rename {Media => DandersFrames/Media}/Classic_Curve.tga (100%) rename {Media => DandersFrames/Media}/DF_AlertBadge.tga (100%) rename {Media => DandersFrames/Media}/DF_AlertMark.tga (100%) rename {Media => DandersFrames/Media}/DF_Bevel.tga (100%) rename {Media => DandersFrames/Media}/DF_Beveled.tga (100%) rename {Media => DandersFrames/Media}/DF_ColorWheel.tga (100%) rename {Media => DandersFrames/Media}/DF_Curve_Smooth.tga (100%) rename {Media => DandersFrames/Media}/DF_Curve_Stops.tga (100%) rename {Media => DandersFrames/Media}/DF_DPS.tga (100%) rename {Media => DandersFrames/Media}/DF_Double.tga (100%) rename {Media => DandersFrames/Media}/DF_ExpireBorder.tga (100%) rename {Media => DandersFrames/Media}/DF_ExpireBorder_Fill.tga (100%) rename {Media => DandersFrames/Media}/DF_ExpireBorder_Thick.tga (100%) rename {Media => DandersFrames/Media}/DF_ExpireBorder_Thin.tga (100%) rename {Media => DandersFrames/Media}/DF_Glass.tga (100%) rename {Media => DandersFrames/Media}/DF_Glossy.tga (100%) rename {Media => DandersFrames/Media}/DF_Glow.tga (100%) rename {Media => DandersFrames/Media}/DF_Gradient_H.tga (100%) rename {Media => DandersFrames/Media}/DF_Gradient_H_Rev.tga (100%) rename {Media => DandersFrames/Media}/DF_Gradient_V.tga (100%) rename {Media => DandersFrames/Media}/DF_Gradient_V_Rev.tga (100%) rename {Media => DandersFrames/Media}/DF_Healer.tga (100%) rename {Media => DandersFrames/Media}/DF_Icon.tga (100%) rename {Media => DandersFrames/Media}/DF_Inset.tga (100%) rename {Media => DandersFrames/Media}/DF_Matte.tga (100%) rename {Media => DandersFrames/Media}/DF_Minimalist.tga (100%) rename {Media => DandersFrames/Media}/DF_Ring.tga (100%) rename {Media => DandersFrames/Media}/DF_Smooth.tga (100%) rename {Media => DandersFrames/Media}/DF_Soft.tga (100%) rename {Media => DandersFrames/Media}/DF_SquareBorder.tga (100%) rename {Media => DandersFrames/Media}/DF_SquareRing.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes_Dense.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes_Medium.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes_Soft.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes_Soft_Wide.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes_Sparse.tga (100%) rename {Media => DandersFrames/Media}/DF_Stripes_Very_Dense.tga (100%) rename {Media => DandersFrames/Media}/DF_Tank.tga (100%) rename {Media => DandersFrames/Media}/Icons/add.tga (100%) rename {Media => DandersFrames/Media}/Icons/check.tga (100%) rename {Media => DandersFrames/Media}/Icons/chevron_right.tga (100%) rename {Media => DandersFrames/Media}/Icons/close.tga (100%) rename {Media => DandersFrames/Media}/Icons/content_copy.tga (100%) rename {Media => DandersFrames/Media}/Icons/delete.tga (100%) rename {Media => DandersFrames/Media}/Icons/dot.tga (100%) rename {Media => DandersFrames/Media}/Icons/download.tga (100%) rename {Media => DandersFrames/Media}/Icons/edit.tga (100%) rename {Media => DandersFrames/Media}/Icons/edit_square.tga (100%) rename {Media => DandersFrames/Media}/Icons/expand_more.tga (100%) rename {Media => DandersFrames/Media}/Icons/filter_alt.tga (100%) rename {Media => DandersFrames/Media}/Icons/filter_list.tga (100%) rename {Media => DandersFrames/Media}/Icons/info.tga (100%) rename {Media => DandersFrames/Media}/Icons/keyboard.tga (100%) rename {Media => DandersFrames/Media}/Icons/lock.tga (100%) rename {Media => DandersFrames/Media}/Icons/lock_open.tga (100%) rename {Media => DandersFrames/Media}/Icons/menu.tga (100%) rename {Media => DandersFrames/Media}/Icons/mouse.tga (100%) rename {Media => DandersFrames/Media}/Icons/notes.tga (100%) rename {Media => DandersFrames/Media}/Icons/preview.tga (100%) rename {Media => DandersFrames/Media}/Icons/preview_off.tga (100%) rename {Media => DandersFrames/Media}/Icons/refresh.tga (100%) rename {Media => DandersFrames/Media}/Icons/remove.tga (100%) rename {Media => DandersFrames/Media}/Icons/reorder.tga (100%) rename {Media => DandersFrames/Media}/Icons/save.tga (100%) rename {Media => DandersFrames/Media}/Icons/search.tga (100%) rename {Media => DandersFrames/Media}/Icons/settings.tga (100%) rename {Media => DandersFrames/Media}/Icons/star.tga (100%) rename {Media => DandersFrames/Media}/Icons/sync.tga (100%) rename {Media => DandersFrames/Media}/Icons/sync_disabled.tga (100%) rename {Media => DandersFrames/Media}/Icons/upload.tga (100%) rename {Media => DandersFrames/Media}/Icons/visibility.tga (100%) rename {Media => DandersFrames/Media}/Icons/visibility_off.tga (100%) rename {Media => DandersFrames/Media}/Icons/warning.tga (100%) rename {Media => DandersFrames/Media}/Icons/widget_small.tga (100%) rename {TestMode => DandersFrames/TestMode}/Shim.lua (100%) rename {TestMode => DandersFrames/TestMode}/TestFramePool.lua (100%) rename {TestMode => DandersFrames/TestMode}/TestMode.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/DataSource.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/MidnightSafe.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/Migration.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/Preview.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/Render.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/Resolver.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/TextDesigner.lua (100%) rename {TextDesigner => DandersFrames/TextDesigner}/UI/Options.lua (100%) rename {Textures => DandersFrames/Textures}/Wizards/overlay_disabled.tga (100%) rename {Textures => DandersFrames/Textures}/Wizards/overlay_enabled.tga (100%) rename {Textures => DandersFrames/Textures}/Wizards/overlay_warning.tga (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a8434bb..e1d739cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: DOMINATED=true for f in $(git diff --name-only HEAD~1..HEAD 2>/dev/null); do case "$f" in - *.md|*.sh|.github/*|.gitignore|.pkgmeta|Core/Changelog.lua) ;; + *.md|*.sh|.github/*|.gitignore|.pkgmeta|DandersFrames/Core/Changelog.lua) ;; *) DOMINATED=false; break ;; esac done @@ -57,8 +57,8 @@ jobs: fi ./generate_changelog.sh - VERSION=$(grep '^## Version:' DandersFrames.toc | sed 's/^## Version: //') - CHANNEL=$(grep 'DF.RELEASE_CHANNEL' Core/Changelog.lua | sed 's/.*= "\(.*\)"/\1/') + VERSION=$(grep '^## Version:' DandersFrames/DandersFrames.toc | sed 's/^## Version: //') + CHANNEL=$(grep 'DF.RELEASE_CHANNEL' DandersFrames/Core/Changelog.lua | sed 's/.*= "\(.*\)"/\1/') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "channel=$CHANNEL" >> $GITHUB_OUTPUT diff --git a/.pkgmeta b/.pkgmeta index e9ab4944..1c5c68e6 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -1,3 +1,26 @@ +# ☠ UNRESOLVED — THIS FILE IS STILL WRITTEN FOR THE OLD SINGLE-FOLDER LAYOUT +# and will NOT package the container layout correctly. Do not cut a release +# until it is fixed and verified. +# +# The repo root now holds DandersFrames/ (and later DandersFrames_Options/), +# so `package-as: DandersFrames` would produce .release/DandersFrames/ with the +# real addon nested one level too deep at .release/DandersFrames/DandersFrames/. +# +# `move-folders` is the mechanism, but the exact semantics matter and I could +# not confirm them offline: whether its keys are relative to .release or to the +# package directory decides whether the obvious config collides with itself +# (moving .release/DandersFrames/DandersFrames onto .release/DandersFrames). +# +# HOW TO SETTLE IT — do not reason about it, measure it: +# 1. Push a throwaway tag (e.g. v0.0.0-pkgtest) on a scratch branch. +# 2. Download the produced zip and look at its top level. +# 3. It must contain DandersFrames/ and DandersFrames_Options/ as SIBLINGS, +# each with its own .toc at its root. +# 4. Delete the tag and the release. +# Checking a comparable multi-folder addon's own .pkgmeta would also answer it. +# +# ⚠ Also verify: release.yml's locale validation globs .release/DandersFrames/ +# Locales/*.lua -- that path may shift with whatever fix lands here. package-as: DandersFrames enable-nolib-creation: no diff --git a/AuraBlacklist/Config.lua b/DandersFrames/AuraBlacklist/Config.lua similarity index 100% rename from AuraBlacklist/Config.lua rename to DandersFrames/AuraBlacklist/Config.lua diff --git a/AuraDesigner/AuraAdapter.lua b/DandersFrames/AuraDesigner/AuraAdapter.lua similarity index 100% rename from AuraDesigner/AuraAdapter.lua rename to DandersFrames/AuraDesigner/AuraAdapter.lua diff --git a/AuraDesigner/Config.lua b/DandersFrames/AuraDesigner/Config.lua similarity index 100% rename from AuraDesigner/Config.lua rename to DandersFrames/AuraDesigner/Config.lua diff --git a/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua similarity index 100% rename from AuraDesigner/Engine.lua rename to DandersFrames/AuraDesigner/Engine.lua diff --git a/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua similarity index 100% rename from AuraDesigner/Factory.lua rename to DandersFrames/AuraDesigner/Factory.lua diff --git a/AuraDesigner/Migrations.lua b/DandersFrames/AuraDesigner/Migrations.lua similarity index 100% rename from AuraDesigner/Migrations.lua rename to DandersFrames/AuraDesigner/Migrations.lua diff --git a/AuraDesigner/SoundEngine.lua b/DandersFrames/AuraDesigner/SoundEngine.lua similarity index 100% rename from AuraDesigner/SoundEngine.lua rename to DandersFrames/AuraDesigner/SoundEngine.lua diff --git a/AuraDesigner/UI/Cards.lua b/DandersFrames/AuraDesigner/UI/Cards.lua similarity index 100% rename from AuraDesigner/UI/Cards.lua rename to DandersFrames/AuraDesigner/UI/Cards.lua diff --git a/AuraDesigner/UI/Editor.lua b/DandersFrames/AuraDesigner/UI/Editor.lua similarity index 100% rename from AuraDesigner/UI/Editor.lua rename to DandersFrames/AuraDesigner/UI/Editor.lua diff --git a/AuraDesigner/UI/Groups.lua b/DandersFrames/AuraDesigner/UI/Groups.lua similarity index 100% rename from AuraDesigner/UI/Groups.lua rename to DandersFrames/AuraDesigner/UI/Groups.lua diff --git a/AuraDesigner/UI/Indicators.lua b/DandersFrames/AuraDesigner/UI/Indicators.lua similarity index 100% rename from AuraDesigner/UI/Indicators.lua rename to DandersFrames/AuraDesigner/UI/Indicators.lua diff --git a/AuraDesigner/UI/Options.lua b/DandersFrames/AuraDesigner/UI/Options.lua similarity index 100% rename from AuraDesigner/UI/Options.lua rename to DandersFrames/AuraDesigner/UI/Options.lua diff --git a/ClickCasting/Bindings.lua b/DandersFrames/ClickCasting/Bindings.lua similarity index 100% rename from ClickCasting/Bindings.lua rename to DandersFrames/ClickCasting/Bindings.lua diff --git a/ClickCasting/Constants.lua b/DandersFrames/ClickCasting/Constants.lua similarity index 100% rename from ClickCasting/Constants.lua rename to DandersFrames/ClickCasting/Constants.lua diff --git a/ClickCasting/Core.lua b/DandersFrames/ClickCasting/Core.lua similarity index 100% rename from ClickCasting/Core.lua rename to DandersFrames/ClickCasting/Core.lua diff --git a/ClickCasting/Events.lua b/DandersFrames/ClickCasting/Events.lua similarity index 100% rename from ClickCasting/Events.lua rename to DandersFrames/ClickCasting/Events.lua diff --git a/ClickCasting/Frames.lua b/DandersFrames/ClickCasting/Frames.lua similarity index 100% rename from ClickCasting/Frames.lua rename to DandersFrames/ClickCasting/Frames.lua diff --git a/ClickCasting/Profiles.lua b/DandersFrames/ClickCasting/Profiles.lua similarity index 100% rename from ClickCasting/Profiles.lua rename to DandersFrames/ClickCasting/Profiles.lua diff --git a/ClickCasting/UI/BindingEditor.lua b/DandersFrames/ClickCasting/UI/BindingEditor.lua similarity index 100% rename from ClickCasting/UI/BindingEditor.lua rename to DandersFrames/ClickCasting/UI/BindingEditor.lua diff --git a/ClickCasting/UI/Dialogs.lua b/DandersFrames/ClickCasting/UI/Dialogs.lua similarity index 100% rename from ClickCasting/UI/Dialogs.lua rename to DandersFrames/ClickCasting/UI/Dialogs.lua diff --git a/ClickCasting/UI/Main.lua b/DandersFrames/ClickCasting/UI/Main.lua similarity index 100% rename from ClickCasting/UI/Main.lua rename to DandersFrames/ClickCasting/UI/Main.lua diff --git a/ClickCasting/UI/ProfilesPanel.lua b/DandersFrames/ClickCasting/UI/ProfilesPanel.lua similarity index 100% rename from ClickCasting/UI/ProfilesPanel.lua rename to DandersFrames/ClickCasting/UI/ProfilesPanel.lua diff --git a/Core.lua b/DandersFrames/Core.lua similarity index 100% rename from Core.lua rename to DandersFrames/Core.lua diff --git a/Core/API.lua b/DandersFrames/Core/API.lua similarity index 100% rename from Core/API.lua rename to DandersFrames/Core/API.lua diff --git a/Core/Changelog.lua b/DandersFrames/Core/Changelog.lua similarity index 100% rename from Core/Changelog.lua rename to DandersFrames/Core/Changelog.lua diff --git a/Core/Config.lua b/DandersFrames/Core/Config.lua similarity index 100% rename from Core/Config.lua rename to DandersFrames/Core/Config.lua diff --git a/Core/ExportCategories.lua b/DandersFrames/Core/ExportCategories.lua similarity index 100% rename from Core/ExportCategories.lua rename to DandersFrames/Core/ExportCategories.lua diff --git a/Core/Profile.lua b/DandersFrames/Core/Profile.lua similarity index 100% rename from Core/Profile.lua rename to DandersFrames/Core/Profile.lua diff --git a/Core/RosterEvents.lua b/DandersFrames/Core/RosterEvents.lua similarity index 100% rename from Core/RosterEvents.lua rename to DandersFrames/Core/RosterEvents.lua diff --git a/DandersFrames.toc b/DandersFrames/DandersFrames.toc similarity index 100% rename from DandersFrames.toc rename to DandersFrames/DandersFrames.toc diff --git a/DandersFrames.xml b/DandersFrames/DandersFrames.xml similarity index 100% rename from DandersFrames.xml rename to DandersFrames/DandersFrames.xml diff --git a/Debug/AtlasBrowser.lua b/DandersFrames/Debug/AtlasBrowser.lua similarity index 100% rename from Debug/AtlasBrowser.lua rename to DandersFrames/Debug/AtlasBrowser.lua diff --git a/Debug/AttachmentScan.lua b/DandersFrames/Debug/AttachmentScan.lua similarity index 100% rename from Debug/AttachmentScan.lua rename to DandersFrames/Debug/AttachmentScan.lua diff --git a/Debug/AuraExplorer.lua b/DandersFrames/Debug/AuraExplorer.lua similarity index 100% rename from Debug/AuraExplorer.lua rename to DandersFrames/Debug/AuraExplorer.lua diff --git a/Debug/DebugConsole.lua b/DandersFrames/Debug/DebugConsole.lua similarity index 100% rename from Debug/DebugConsole.lua rename to DandersFrames/Debug/DebugConsole.lua diff --git a/Debug/MemoryTest.lua b/DandersFrames/Debug/MemoryTest.lua similarity index 100% rename from Debug/MemoryTest.lua rename to DandersFrames/Debug/MemoryTest.lua diff --git a/Debug/Profiler.lua b/DandersFrames/Debug/Profiler.lua similarity index 100% rename from Debug/Profiler.lua rename to DandersFrames/Debug/Profiler.lua diff --git a/Designer/Presets.lua b/DandersFrames/Designer/Presets.lua similarity index 100% rename from Designer/Presets.lua rename to DandersFrames/Designer/Presets.lua diff --git a/Features/Auras.lua b/DandersFrames/Features/Auras.lua similarity index 100% rename from Features/Auras.lua rename to DandersFrames/Features/Auras.lua diff --git a/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua similarity index 100% rename from Features/Dispel.lua rename to DandersFrames/Features/Dispel.lua diff --git a/Features/ElementAppearance.lua b/DandersFrames/Features/ElementAppearance.lua similarity index 100% rename from Features/ElementAppearance.lua rename to DandersFrames/Features/ElementAppearance.lua diff --git a/Features/Expiration.lua b/DandersFrames/Features/Expiration.lua similarity index 100% rename from Features/Expiration.lua rename to DandersFrames/Features/Expiration.lua diff --git a/Features/FlatRaidFrames.lua b/DandersFrames/Features/FlatRaidFrames.lua similarity index 100% rename from Features/FlatRaidFrames.lua rename to DandersFrames/Features/FlatRaidFrames.lua diff --git a/Features/FrameSort.lua b/DandersFrames/Features/FrameSort.lua similarity index 100% rename from Features/FrameSort.lua rename to DandersFrames/Features/FrameSort.lua diff --git a/Features/HealthFade.lua b/DandersFrames/Features/HealthFade.lua similarity index 100% rename from Features/HealthFade.lua rename to DandersFrames/Features/HealthFade.lua diff --git a/Features/Highlights.lua b/DandersFrames/Features/Highlights.lua similarity index 100% rename from Features/Highlights.lua rename to DandersFrames/Features/Highlights.lua diff --git a/Features/Nicknames.lua b/DandersFrames/Features/Nicknames.lua similarity index 100% rename from Features/Nicknames.lua rename to DandersFrames/Features/Nicknames.lua diff --git a/Features/NicknamesComm.lua b/DandersFrames/Features/NicknamesComm.lua similarity index 100% rename from Features/NicknamesComm.lua rename to DandersFrames/Features/NicknamesComm.lua diff --git a/Features/PinnedFrames.lua b/DandersFrames/Features/PinnedFrames.lua similarity index 100% rename from Features/PinnedFrames.lua rename to DandersFrames/Features/PinnedFrames.lua diff --git a/Features/Range.lua b/DandersFrames/Features/Range.lua similarity index 100% rename from Features/Range.lua rename to DandersFrames/Features/Range.lua diff --git a/Features/Search.lua b/DandersFrames/Features/Search.lua similarity index 100% rename from Features/Search.lua rename to DandersFrames/Features/Search.lua diff --git a/Features/SecureSort.lua b/DandersFrames/Features/SecureSort.lua similarity index 100% rename from Features/SecureSort.lua rename to DandersFrames/Features/SecureSort.lua diff --git a/Features/Sort.lua b/DandersFrames/Features/Sort.lua similarity index 100% rename from Features/Sort.lua rename to DandersFrames/Features/Sort.lua diff --git a/Features/TargetedSpells.lua b/DandersFrames/Features/TargetedSpells.lua similarity index 100% rename from Features/TargetedSpells.lua rename to DandersFrames/Features/TargetedSpells.lua diff --git a/Features/VersionCheck.lua b/DandersFrames/Features/VersionCheck.lua similarity index 100% rename from Features/VersionCheck.lua rename to DandersFrames/Features/VersionCheck.lua diff --git a/FilterRegistry/Registry.lua b/DandersFrames/FilterRegistry/Registry.lua similarity index 100% rename from FilterRegistry/Registry.lua rename to DandersFrames/FilterRegistry/Registry.lua diff --git a/FilterRegistry/SpellDB.lua b/DandersFrames/FilterRegistry/SpellDB.lua similarity index 100% rename from FilterRegistry/SpellDB.lua rename to DandersFrames/FilterRegistry/SpellDB.lua diff --git a/FilterRegistry/UI/Options.lua b/DandersFrames/FilterRegistry/UI/Options.lua similarity index 100% rename from FilterRegistry/UI/Options.lua rename to DandersFrames/FilterRegistry/UI/Options.lua diff --git a/FilterRegistry/UI/SpellPicker.lua b/DandersFrames/FilterRegistry/UI/SpellPicker.lua similarity index 100% rename from FilterRegistry/UI/SpellPicker.lua rename to DandersFrames/FilterRegistry/UI/SpellPicker.lua diff --git a/Fonts/Expressway.ttf b/DandersFrames/Fonts/Expressway.ttf similarity index 100% rename from Fonts/Expressway.ttf rename to DandersFrames/Fonts/Expressway.ttf diff --git a/Fonts/Roboto-Bold.ttf b/DandersFrames/Fonts/Roboto-Bold.ttf similarity index 100% rename from Fonts/Roboto-Bold.ttf rename to DandersFrames/Fonts/Roboto-Bold.ttf diff --git a/Fonts/Roboto-SemiBold.ttf b/DandersFrames/Fonts/Roboto-SemiBold.ttf similarity index 100% rename from Fonts/Roboto-SemiBold.ttf rename to DandersFrames/Fonts/Roboto-SemiBold.ttf diff --git a/Fonts/RobotoMono-Bold.ttf b/DandersFrames/Fonts/RobotoMono-Bold.ttf similarity index 100% rename from Fonts/RobotoMono-Bold.ttf rename to DandersFrames/Fonts/RobotoMono-Bold.ttf diff --git a/Fonts/RobotoMono-SemiBold.ttf b/DandersFrames/Fonts/RobotoMono-SemiBold.ttf similarity index 100% rename from Fonts/RobotoMono-SemiBold.ttf rename to DandersFrames/Fonts/RobotoMono-SemiBold.ttf diff --git a/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua similarity index 100% rename from Frames/AuraContainer.lua rename to DandersFrames/Frames/AuraContainer.lua diff --git a/Frames/Bars.lua b/DandersFrames/Frames/Bars.lua similarity index 100% rename from Frames/Bars.lua rename to DandersFrames/Frames/Bars.lua diff --git a/Frames/Border.lua b/DandersFrames/Frames/Border.lua similarity index 100% rename from Frames/Border.lua rename to DandersFrames/Frames/Border.lua diff --git a/Frames/Colors.lua b/DandersFrames/Frames/Colors.lua similarity index 100% rename from Frames/Colors.lua rename to DandersFrames/Frames/Colors.lua diff --git a/Frames/Core.lua b/DandersFrames/Frames/Core.lua similarity index 100% rename from Frames/Core.lua rename to DandersFrames/Frames/Core.lua diff --git a/Frames/Create.lua b/DandersFrames/Frames/Create.lua similarity index 100% rename from Frames/Create.lua rename to DandersFrames/Frames/Create.lua diff --git a/Frames/Headers.lua b/DandersFrames/Frames/Headers.lua similarity index 100% rename from Frames/Headers.lua rename to DandersFrames/Frames/Headers.lua diff --git a/Frames/Icons.lua b/DandersFrames/Frames/Icons.lua similarity index 100% rename from Frames/Icons.lua rename to DandersFrames/Frames/Icons.lua diff --git a/Frames/Init.lua b/DandersFrames/Frames/Init.lua similarity index 100% rename from Frames/Init.lua rename to DandersFrames/Frames/Init.lua diff --git a/Frames/Pets.lua b/DandersFrames/Frames/Pets.lua similarity index 100% rename from Frames/Pets.lua rename to DandersFrames/Frames/Pets.lua diff --git a/Frames/Position.lua b/DandersFrames/Frames/Position.lua similarity index 100% rename from Frames/Position.lua rename to DandersFrames/Frames/Position.lua diff --git a/Frames/ReducedMaxHealth.lua b/DandersFrames/Frames/ReducedMaxHealth.lua similarity index 100% rename from Frames/ReducedMaxHealth.lua rename to DandersFrames/Frames/ReducedMaxHealth.lua diff --git a/Frames/StatusIcons.lua b/DandersFrames/Frames/StatusIcons.lua similarity index 100% rename from Frames/StatusIcons.lua rename to DandersFrames/Frames/StatusIcons.lua diff --git a/Frames/TextStyle.lua b/DandersFrames/Frames/TextStyle.lua similarity index 100% rename from Frames/TextStyle.lua rename to DandersFrames/Frames/TextStyle.lua diff --git a/Frames/Update.lua b/DandersFrames/Frames/Update.lua similarity index 100% rename from Frames/Update.lua rename to DandersFrames/Frames/Update.lua diff --git a/GUI/ColorPicker.lua b/DandersFrames/GUI/ColorPicker.lua similarity index 100% rename from GUI/ColorPicker.lua rename to DandersFrames/GUI/ColorPicker.lua diff --git a/GUI/Controls.lua b/DandersFrames/GUI/Controls.lua similarity index 100% rename from GUI/Controls.lua rename to DandersFrames/GUI/Controls.lua diff --git a/GUI/DFFonts.lua b/DandersFrames/GUI/DFFonts.lua similarity index 100% rename from GUI/DFFonts.lua rename to DandersFrames/GUI/DFFonts.lua diff --git a/GUI/GUI.lua b/DandersFrames/GUI/GUI.lua similarity index 100% rename from GUI/GUI.lua rename to DandersFrames/GUI/GUI.lua diff --git a/GUI/IconLib.lua b/DandersFrames/GUI/IconLib.lua similarity index 100% rename from GUI/IconLib.lua rename to DandersFrames/GUI/IconLib.lua diff --git a/GUI/Pages/Auras.lua b/DandersFrames/GUI/Pages/Auras.lua similarity index 100% rename from GUI/Pages/Auras.lua rename to DandersFrames/GUI/Pages/Auras.lua diff --git a/GUI/Pages/AutoProfiles.lua b/DandersFrames/GUI/Pages/AutoProfiles.lua similarity index 100% rename from GUI/Pages/AutoProfiles.lua rename to DandersFrames/GUI/Pages/AutoProfiles.lua diff --git a/GUI/Pages/Frames.lua b/DandersFrames/GUI/Pages/Frames.lua similarity index 100% rename from GUI/Pages/Frames.lua rename to DandersFrames/GUI/Pages/Frames.lua diff --git a/GUI/Pages/Indicators.lua b/DandersFrames/GUI/Pages/Indicators.lua similarity index 100% rename from GUI/Pages/Indicators.lua rename to DandersFrames/GUI/Pages/Indicators.lua diff --git a/GUI/Pages/Modules.lua b/DandersFrames/GUI/Pages/Modules.lua similarity index 100% rename from GUI/Pages/Modules.lua rename to DandersFrames/GUI/Pages/Modules.lua diff --git a/GUI/Pages/NicknamesPage.lua b/DandersFrames/GUI/Pages/NicknamesPage.lua similarity index 100% rename from GUI/Pages/NicknamesPage.lua rename to DandersFrames/GUI/Pages/NicknamesPage.lua diff --git a/GUI/Pages/NicknamesPicker.lua b/DandersFrames/GUI/Pages/NicknamesPicker.lua similarity index 100% rename from GUI/Pages/NicknamesPicker.lua rename to DandersFrames/GUI/Pages/NicknamesPicker.lua diff --git a/GUI/Pages/Options.lua b/DandersFrames/GUI/Pages/Options.lua similarity index 100% rename from GUI/Pages/Options.lua rename to DandersFrames/GUI/Pages/Options.lua diff --git a/GUI/Panel.lua b/DandersFrames/GUI/Panel.lua similarity index 100% rename from GUI/Panel.lua rename to DandersFrames/GUI/Panel.lua diff --git a/GUI/Popup.lua b/DandersFrames/GUI/Popup.lua similarity index 100% rename from GUI/Popup.lua rename to DandersFrames/GUI/Popup.lua diff --git a/GUI/Sections.lua b/DandersFrames/GUI/Sections.lua similarity index 100% rename from GUI/Sections.lua rename to DandersFrames/GUI/Sections.lua diff --git a/GUI/Widgets.lua b/DandersFrames/GUI/Widgets.lua similarity index 100% rename from GUI/Widgets.lua rename to DandersFrames/GUI/Widgets.lua diff --git a/Libs/AceLocale-3.0/AceLocale-3.0.lua b/DandersFrames/Libs/AceLocale-3.0/AceLocale-3.0.lua similarity index 100% rename from Libs/AceLocale-3.0/AceLocale-3.0.lua rename to DandersFrames/Libs/AceLocale-3.0/AceLocale-3.0.lua diff --git a/Libs/AceSerializer-3.0/AceSerializer-3.0.lua b/DandersFrames/Libs/AceSerializer-3.0/AceSerializer-3.0.lua similarity index 100% rename from Libs/AceSerializer-3.0/AceSerializer-3.0.lua rename to DandersFrames/Libs/AceSerializer-3.0/AceSerializer-3.0.lua diff --git a/Libs/AceSerializer-3.0/AceSerializer-3.0.xml b/DandersFrames/Libs/AceSerializer-3.0/AceSerializer-3.0.xml similarity index 100% rename from Libs/AceSerializer-3.0/AceSerializer-3.0.xml rename to DandersFrames/Libs/AceSerializer-3.0/AceSerializer-3.0.xml diff --git a/Libs/CallbackHandler-1.0/CallbackHandler-1.0.lua b/DandersFrames/Libs/CallbackHandler-1.0/CallbackHandler-1.0.lua similarity index 100% rename from Libs/CallbackHandler-1.0/CallbackHandler-1.0.lua rename to DandersFrames/Libs/CallbackHandler-1.0/CallbackHandler-1.0.lua diff --git a/Libs/CallbackHandler-1.0/CallbackHandler-1.0.xml b/DandersFrames/Libs/CallbackHandler-1.0/CallbackHandler-1.0.xml similarity index 100% rename from Libs/CallbackHandler-1.0/CallbackHandler-1.0.xml rename to DandersFrames/Libs/CallbackHandler-1.0/CallbackHandler-1.0.xml diff --git a/Libs/LibDBIcon-1.0/LibDBIcon-1.0.lua b/DandersFrames/Libs/LibDBIcon-1.0/LibDBIcon-1.0.lua similarity index 100% rename from Libs/LibDBIcon-1.0/LibDBIcon-1.0.lua rename to DandersFrames/Libs/LibDBIcon-1.0/LibDBIcon-1.0.lua diff --git a/Libs/LibDataBroker-1.1/Changelog-libdatabroker-1-1-v1.1.4.txt b/DandersFrames/Libs/LibDataBroker-1.1/Changelog-libdatabroker-1-1-v1.1.4.txt similarity index 100% rename from Libs/LibDataBroker-1.1/Changelog-libdatabroker-1-1-v1.1.4.txt rename to DandersFrames/Libs/LibDataBroker-1.1/Changelog-libdatabroker-1-1-v1.1.4.txt diff --git a/Libs/LibDataBroker-1.1/LibDataBroker-1.1.lua b/DandersFrames/Libs/LibDataBroker-1.1/LibDataBroker-1.1.lua similarity index 100% rename from Libs/LibDataBroker-1.1/LibDataBroker-1.1.lua rename to DandersFrames/Libs/LibDataBroker-1.1/LibDataBroker-1.1.lua diff --git a/Libs/LibDataBroker-1.1/README.textile b/DandersFrames/Libs/LibDataBroker-1.1/README.textile similarity index 100% rename from Libs/LibDataBroker-1.1/README.textile rename to DandersFrames/Libs/LibDataBroker-1.1/README.textile diff --git a/Libs/LibDeflate/LibDeflate.lua b/DandersFrames/Libs/LibDeflate/LibDeflate.lua similarity index 100% rename from Libs/LibDeflate/LibDeflate.lua rename to DandersFrames/Libs/LibDeflate/LibDeflate.lua diff --git a/Libs/LibDeflate/lib.xml b/DandersFrames/Libs/LibDeflate/lib.xml similarity index 100% rename from Libs/LibDeflate/lib.xml rename to DandersFrames/Libs/LibDeflate/lib.xml diff --git a/Libs/LibSerialize/LibSerialize.lua b/DandersFrames/Libs/LibSerialize/LibSerialize.lua similarity index 100% rename from Libs/LibSerialize/LibSerialize.lua rename to DandersFrames/Libs/LibSerialize/LibSerialize.lua diff --git a/Libs/LibSerialize/lib.xml b/DandersFrames/Libs/LibSerialize/lib.xml similarity index 100% rename from Libs/LibSerialize/lib.xml rename to DandersFrames/Libs/LibSerialize/lib.xml diff --git a/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.lua b/DandersFrames/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.lua similarity index 100% rename from Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.lua rename to DandersFrames/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.lua diff --git a/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.xml b/DandersFrames/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.xml similarity index 100% rename from Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.xml rename to DandersFrames/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.xml diff --git a/Libs/LibStub/LibStub.lua b/DandersFrames/Libs/LibStub/LibStub.lua similarity index 100% rename from Libs/LibStub/LibStub.lua rename to DandersFrames/Libs/LibStub/LibStub.lua diff --git a/Locales/deDE.lua b/DandersFrames/Locales/deDE.lua similarity index 100% rename from Locales/deDE.lua rename to DandersFrames/Locales/deDE.lua diff --git a/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua similarity index 100% rename from Locales/enUS.lua rename to DandersFrames/Locales/enUS.lua diff --git a/Locales/esES.lua b/DandersFrames/Locales/esES.lua similarity index 100% rename from Locales/esES.lua rename to DandersFrames/Locales/esES.lua diff --git a/Locales/esMX.lua b/DandersFrames/Locales/esMX.lua similarity index 100% rename from Locales/esMX.lua rename to DandersFrames/Locales/esMX.lua diff --git a/Locales/frFR.lua b/DandersFrames/Locales/frFR.lua similarity index 100% rename from Locales/frFR.lua rename to DandersFrames/Locales/frFR.lua diff --git a/Locales/itIT.lua b/DandersFrames/Locales/itIT.lua similarity index 100% rename from Locales/itIT.lua rename to DandersFrames/Locales/itIT.lua diff --git a/Locales/koKR.lua b/DandersFrames/Locales/koKR.lua similarity index 100% rename from Locales/koKR.lua rename to DandersFrames/Locales/koKR.lua diff --git a/Locales/ptBR.lua b/DandersFrames/Locales/ptBR.lua similarity index 100% rename from Locales/ptBR.lua rename to DandersFrames/Locales/ptBR.lua diff --git a/Locales/ruRU.lua b/DandersFrames/Locales/ruRU.lua similarity index 100% rename from Locales/ruRU.lua rename to DandersFrames/Locales/ruRU.lua diff --git a/Locales/zhCN.lua b/DandersFrames/Locales/zhCN.lua similarity index 100% rename from Locales/zhCN.lua rename to DandersFrames/Locales/zhCN.lua diff --git a/Locales/zhTW.lua b/DandersFrames/Locales/zhTW.lua similarity index 100% rename from Locales/zhTW.lua rename to DandersFrames/Locales/zhTW.lua diff --git a/Media/Classic_Curve.tga b/DandersFrames/Media/Classic_Curve.tga similarity index 100% rename from Media/Classic_Curve.tga rename to DandersFrames/Media/Classic_Curve.tga diff --git a/Media/DF_AlertBadge.tga b/DandersFrames/Media/DF_AlertBadge.tga similarity index 100% rename from Media/DF_AlertBadge.tga rename to DandersFrames/Media/DF_AlertBadge.tga diff --git a/Media/DF_AlertMark.tga b/DandersFrames/Media/DF_AlertMark.tga similarity index 100% rename from Media/DF_AlertMark.tga rename to DandersFrames/Media/DF_AlertMark.tga diff --git a/Media/DF_Bevel.tga b/DandersFrames/Media/DF_Bevel.tga similarity index 100% rename from Media/DF_Bevel.tga rename to DandersFrames/Media/DF_Bevel.tga diff --git a/Media/DF_Beveled.tga b/DandersFrames/Media/DF_Beveled.tga similarity index 100% rename from Media/DF_Beveled.tga rename to DandersFrames/Media/DF_Beveled.tga diff --git a/Media/DF_ColorWheel.tga b/DandersFrames/Media/DF_ColorWheel.tga similarity index 100% rename from Media/DF_ColorWheel.tga rename to DandersFrames/Media/DF_ColorWheel.tga diff --git a/Media/DF_Curve_Smooth.tga b/DandersFrames/Media/DF_Curve_Smooth.tga similarity index 100% rename from Media/DF_Curve_Smooth.tga rename to DandersFrames/Media/DF_Curve_Smooth.tga diff --git a/Media/DF_Curve_Stops.tga b/DandersFrames/Media/DF_Curve_Stops.tga similarity index 100% rename from Media/DF_Curve_Stops.tga rename to DandersFrames/Media/DF_Curve_Stops.tga diff --git a/Media/DF_DPS.tga b/DandersFrames/Media/DF_DPS.tga similarity index 100% rename from Media/DF_DPS.tga rename to DandersFrames/Media/DF_DPS.tga diff --git a/Media/DF_Double.tga b/DandersFrames/Media/DF_Double.tga similarity index 100% rename from Media/DF_Double.tga rename to DandersFrames/Media/DF_Double.tga diff --git a/Media/DF_ExpireBorder.tga b/DandersFrames/Media/DF_ExpireBorder.tga similarity index 100% rename from Media/DF_ExpireBorder.tga rename to DandersFrames/Media/DF_ExpireBorder.tga diff --git a/Media/DF_ExpireBorder_Fill.tga b/DandersFrames/Media/DF_ExpireBorder_Fill.tga similarity index 100% rename from Media/DF_ExpireBorder_Fill.tga rename to DandersFrames/Media/DF_ExpireBorder_Fill.tga diff --git a/Media/DF_ExpireBorder_Thick.tga b/DandersFrames/Media/DF_ExpireBorder_Thick.tga similarity index 100% rename from Media/DF_ExpireBorder_Thick.tga rename to DandersFrames/Media/DF_ExpireBorder_Thick.tga diff --git a/Media/DF_ExpireBorder_Thin.tga b/DandersFrames/Media/DF_ExpireBorder_Thin.tga similarity index 100% rename from Media/DF_ExpireBorder_Thin.tga rename to DandersFrames/Media/DF_ExpireBorder_Thin.tga diff --git a/Media/DF_Glass.tga b/DandersFrames/Media/DF_Glass.tga similarity index 100% rename from Media/DF_Glass.tga rename to DandersFrames/Media/DF_Glass.tga diff --git a/Media/DF_Glossy.tga b/DandersFrames/Media/DF_Glossy.tga similarity index 100% rename from Media/DF_Glossy.tga rename to DandersFrames/Media/DF_Glossy.tga diff --git a/Media/DF_Glow.tga b/DandersFrames/Media/DF_Glow.tga similarity index 100% rename from Media/DF_Glow.tga rename to DandersFrames/Media/DF_Glow.tga diff --git a/Media/DF_Gradient_H.tga b/DandersFrames/Media/DF_Gradient_H.tga similarity index 100% rename from Media/DF_Gradient_H.tga rename to DandersFrames/Media/DF_Gradient_H.tga diff --git a/Media/DF_Gradient_H_Rev.tga b/DandersFrames/Media/DF_Gradient_H_Rev.tga similarity index 100% rename from Media/DF_Gradient_H_Rev.tga rename to DandersFrames/Media/DF_Gradient_H_Rev.tga diff --git a/Media/DF_Gradient_V.tga b/DandersFrames/Media/DF_Gradient_V.tga similarity index 100% rename from Media/DF_Gradient_V.tga rename to DandersFrames/Media/DF_Gradient_V.tga diff --git a/Media/DF_Gradient_V_Rev.tga b/DandersFrames/Media/DF_Gradient_V_Rev.tga similarity index 100% rename from Media/DF_Gradient_V_Rev.tga rename to DandersFrames/Media/DF_Gradient_V_Rev.tga diff --git a/Media/DF_Healer.tga b/DandersFrames/Media/DF_Healer.tga similarity index 100% rename from Media/DF_Healer.tga rename to DandersFrames/Media/DF_Healer.tga diff --git a/Media/DF_Icon.tga b/DandersFrames/Media/DF_Icon.tga similarity index 100% rename from Media/DF_Icon.tga rename to DandersFrames/Media/DF_Icon.tga diff --git a/Media/DF_Inset.tga b/DandersFrames/Media/DF_Inset.tga similarity index 100% rename from Media/DF_Inset.tga rename to DandersFrames/Media/DF_Inset.tga diff --git a/Media/DF_Matte.tga b/DandersFrames/Media/DF_Matte.tga similarity index 100% rename from Media/DF_Matte.tga rename to DandersFrames/Media/DF_Matte.tga diff --git a/Media/DF_Minimalist.tga b/DandersFrames/Media/DF_Minimalist.tga similarity index 100% rename from Media/DF_Minimalist.tga rename to DandersFrames/Media/DF_Minimalist.tga diff --git a/Media/DF_Ring.tga b/DandersFrames/Media/DF_Ring.tga similarity index 100% rename from Media/DF_Ring.tga rename to DandersFrames/Media/DF_Ring.tga diff --git a/Media/DF_Smooth.tga b/DandersFrames/Media/DF_Smooth.tga similarity index 100% rename from Media/DF_Smooth.tga rename to DandersFrames/Media/DF_Smooth.tga diff --git a/Media/DF_Soft.tga b/DandersFrames/Media/DF_Soft.tga similarity index 100% rename from Media/DF_Soft.tga rename to DandersFrames/Media/DF_Soft.tga diff --git a/Media/DF_SquareBorder.tga b/DandersFrames/Media/DF_SquareBorder.tga similarity index 100% rename from Media/DF_SquareBorder.tga rename to DandersFrames/Media/DF_SquareBorder.tga diff --git a/Media/DF_SquareRing.tga b/DandersFrames/Media/DF_SquareRing.tga similarity index 100% rename from Media/DF_SquareRing.tga rename to DandersFrames/Media/DF_SquareRing.tga diff --git a/Media/DF_Stripes.tga b/DandersFrames/Media/DF_Stripes.tga similarity index 100% rename from Media/DF_Stripes.tga rename to DandersFrames/Media/DF_Stripes.tga diff --git a/Media/DF_Stripes_Dense.tga b/DandersFrames/Media/DF_Stripes_Dense.tga similarity index 100% rename from Media/DF_Stripes_Dense.tga rename to DandersFrames/Media/DF_Stripes_Dense.tga diff --git a/Media/DF_Stripes_Medium.tga b/DandersFrames/Media/DF_Stripes_Medium.tga similarity index 100% rename from Media/DF_Stripes_Medium.tga rename to DandersFrames/Media/DF_Stripes_Medium.tga diff --git a/Media/DF_Stripes_Soft.tga b/DandersFrames/Media/DF_Stripes_Soft.tga similarity index 100% rename from Media/DF_Stripes_Soft.tga rename to DandersFrames/Media/DF_Stripes_Soft.tga diff --git a/Media/DF_Stripes_Soft_Wide.tga b/DandersFrames/Media/DF_Stripes_Soft_Wide.tga similarity index 100% rename from Media/DF_Stripes_Soft_Wide.tga rename to DandersFrames/Media/DF_Stripes_Soft_Wide.tga diff --git a/Media/DF_Stripes_Sparse.tga b/DandersFrames/Media/DF_Stripes_Sparse.tga similarity index 100% rename from Media/DF_Stripes_Sparse.tga rename to DandersFrames/Media/DF_Stripes_Sparse.tga diff --git a/Media/DF_Stripes_Very_Dense.tga b/DandersFrames/Media/DF_Stripes_Very_Dense.tga similarity index 100% rename from Media/DF_Stripes_Very_Dense.tga rename to DandersFrames/Media/DF_Stripes_Very_Dense.tga diff --git a/Media/DF_Tank.tga b/DandersFrames/Media/DF_Tank.tga similarity index 100% rename from Media/DF_Tank.tga rename to DandersFrames/Media/DF_Tank.tga diff --git a/Media/Icons/add.tga b/DandersFrames/Media/Icons/add.tga similarity index 100% rename from Media/Icons/add.tga rename to DandersFrames/Media/Icons/add.tga diff --git a/Media/Icons/check.tga b/DandersFrames/Media/Icons/check.tga similarity index 100% rename from Media/Icons/check.tga rename to DandersFrames/Media/Icons/check.tga diff --git a/Media/Icons/chevron_right.tga b/DandersFrames/Media/Icons/chevron_right.tga similarity index 100% rename from Media/Icons/chevron_right.tga rename to DandersFrames/Media/Icons/chevron_right.tga diff --git a/Media/Icons/close.tga b/DandersFrames/Media/Icons/close.tga similarity index 100% rename from Media/Icons/close.tga rename to DandersFrames/Media/Icons/close.tga diff --git a/Media/Icons/content_copy.tga b/DandersFrames/Media/Icons/content_copy.tga similarity index 100% rename from Media/Icons/content_copy.tga rename to DandersFrames/Media/Icons/content_copy.tga diff --git a/Media/Icons/delete.tga b/DandersFrames/Media/Icons/delete.tga similarity index 100% rename from Media/Icons/delete.tga rename to DandersFrames/Media/Icons/delete.tga diff --git a/Media/Icons/dot.tga b/DandersFrames/Media/Icons/dot.tga similarity index 100% rename from Media/Icons/dot.tga rename to DandersFrames/Media/Icons/dot.tga diff --git a/Media/Icons/download.tga b/DandersFrames/Media/Icons/download.tga similarity index 100% rename from Media/Icons/download.tga rename to DandersFrames/Media/Icons/download.tga diff --git a/Media/Icons/edit.tga b/DandersFrames/Media/Icons/edit.tga similarity index 100% rename from Media/Icons/edit.tga rename to DandersFrames/Media/Icons/edit.tga diff --git a/Media/Icons/edit_square.tga b/DandersFrames/Media/Icons/edit_square.tga similarity index 100% rename from Media/Icons/edit_square.tga rename to DandersFrames/Media/Icons/edit_square.tga diff --git a/Media/Icons/expand_more.tga b/DandersFrames/Media/Icons/expand_more.tga similarity index 100% rename from Media/Icons/expand_more.tga rename to DandersFrames/Media/Icons/expand_more.tga diff --git a/Media/Icons/filter_alt.tga b/DandersFrames/Media/Icons/filter_alt.tga similarity index 100% rename from Media/Icons/filter_alt.tga rename to DandersFrames/Media/Icons/filter_alt.tga diff --git a/Media/Icons/filter_list.tga b/DandersFrames/Media/Icons/filter_list.tga similarity index 100% rename from Media/Icons/filter_list.tga rename to DandersFrames/Media/Icons/filter_list.tga diff --git a/Media/Icons/info.tga b/DandersFrames/Media/Icons/info.tga similarity index 100% rename from Media/Icons/info.tga rename to DandersFrames/Media/Icons/info.tga diff --git a/Media/Icons/keyboard.tga b/DandersFrames/Media/Icons/keyboard.tga similarity index 100% rename from Media/Icons/keyboard.tga rename to DandersFrames/Media/Icons/keyboard.tga diff --git a/Media/Icons/lock.tga b/DandersFrames/Media/Icons/lock.tga similarity index 100% rename from Media/Icons/lock.tga rename to DandersFrames/Media/Icons/lock.tga diff --git a/Media/Icons/lock_open.tga b/DandersFrames/Media/Icons/lock_open.tga similarity index 100% rename from Media/Icons/lock_open.tga rename to DandersFrames/Media/Icons/lock_open.tga diff --git a/Media/Icons/menu.tga b/DandersFrames/Media/Icons/menu.tga similarity index 100% rename from Media/Icons/menu.tga rename to DandersFrames/Media/Icons/menu.tga diff --git a/Media/Icons/mouse.tga b/DandersFrames/Media/Icons/mouse.tga similarity index 100% rename from Media/Icons/mouse.tga rename to DandersFrames/Media/Icons/mouse.tga diff --git a/Media/Icons/notes.tga b/DandersFrames/Media/Icons/notes.tga similarity index 100% rename from Media/Icons/notes.tga rename to DandersFrames/Media/Icons/notes.tga diff --git a/Media/Icons/preview.tga b/DandersFrames/Media/Icons/preview.tga similarity index 100% rename from Media/Icons/preview.tga rename to DandersFrames/Media/Icons/preview.tga diff --git a/Media/Icons/preview_off.tga b/DandersFrames/Media/Icons/preview_off.tga similarity index 100% rename from Media/Icons/preview_off.tga rename to DandersFrames/Media/Icons/preview_off.tga diff --git a/Media/Icons/refresh.tga b/DandersFrames/Media/Icons/refresh.tga similarity index 100% rename from Media/Icons/refresh.tga rename to DandersFrames/Media/Icons/refresh.tga diff --git a/Media/Icons/remove.tga b/DandersFrames/Media/Icons/remove.tga similarity index 100% rename from Media/Icons/remove.tga rename to DandersFrames/Media/Icons/remove.tga diff --git a/Media/Icons/reorder.tga b/DandersFrames/Media/Icons/reorder.tga similarity index 100% rename from Media/Icons/reorder.tga rename to DandersFrames/Media/Icons/reorder.tga diff --git a/Media/Icons/save.tga b/DandersFrames/Media/Icons/save.tga similarity index 100% rename from Media/Icons/save.tga rename to DandersFrames/Media/Icons/save.tga diff --git a/Media/Icons/search.tga b/DandersFrames/Media/Icons/search.tga similarity index 100% rename from Media/Icons/search.tga rename to DandersFrames/Media/Icons/search.tga diff --git a/Media/Icons/settings.tga b/DandersFrames/Media/Icons/settings.tga similarity index 100% rename from Media/Icons/settings.tga rename to DandersFrames/Media/Icons/settings.tga diff --git a/Media/Icons/star.tga b/DandersFrames/Media/Icons/star.tga similarity index 100% rename from Media/Icons/star.tga rename to DandersFrames/Media/Icons/star.tga diff --git a/Media/Icons/sync.tga b/DandersFrames/Media/Icons/sync.tga similarity index 100% rename from Media/Icons/sync.tga rename to DandersFrames/Media/Icons/sync.tga diff --git a/Media/Icons/sync_disabled.tga b/DandersFrames/Media/Icons/sync_disabled.tga similarity index 100% rename from Media/Icons/sync_disabled.tga rename to DandersFrames/Media/Icons/sync_disabled.tga diff --git a/Media/Icons/upload.tga b/DandersFrames/Media/Icons/upload.tga similarity index 100% rename from Media/Icons/upload.tga rename to DandersFrames/Media/Icons/upload.tga diff --git a/Media/Icons/visibility.tga b/DandersFrames/Media/Icons/visibility.tga similarity index 100% rename from Media/Icons/visibility.tga rename to DandersFrames/Media/Icons/visibility.tga diff --git a/Media/Icons/visibility_off.tga b/DandersFrames/Media/Icons/visibility_off.tga similarity index 100% rename from Media/Icons/visibility_off.tga rename to DandersFrames/Media/Icons/visibility_off.tga diff --git a/Media/Icons/warning.tga b/DandersFrames/Media/Icons/warning.tga similarity index 100% rename from Media/Icons/warning.tga rename to DandersFrames/Media/Icons/warning.tga diff --git a/Media/Icons/widget_small.tga b/DandersFrames/Media/Icons/widget_small.tga similarity index 100% rename from Media/Icons/widget_small.tga rename to DandersFrames/Media/Icons/widget_small.tga diff --git a/TestMode/Shim.lua b/DandersFrames/TestMode/Shim.lua similarity index 100% rename from TestMode/Shim.lua rename to DandersFrames/TestMode/Shim.lua diff --git a/TestMode/TestFramePool.lua b/DandersFrames/TestMode/TestFramePool.lua similarity index 100% rename from TestMode/TestFramePool.lua rename to DandersFrames/TestMode/TestFramePool.lua diff --git a/TestMode/TestMode.lua b/DandersFrames/TestMode/TestMode.lua similarity index 100% rename from TestMode/TestMode.lua rename to DandersFrames/TestMode/TestMode.lua diff --git a/TextDesigner/DataSource.lua b/DandersFrames/TextDesigner/DataSource.lua similarity index 100% rename from TextDesigner/DataSource.lua rename to DandersFrames/TextDesigner/DataSource.lua diff --git a/TextDesigner/MidnightSafe.lua b/DandersFrames/TextDesigner/MidnightSafe.lua similarity index 100% rename from TextDesigner/MidnightSafe.lua rename to DandersFrames/TextDesigner/MidnightSafe.lua diff --git a/TextDesigner/Migration.lua b/DandersFrames/TextDesigner/Migration.lua similarity index 100% rename from TextDesigner/Migration.lua rename to DandersFrames/TextDesigner/Migration.lua diff --git a/TextDesigner/Preview.lua b/DandersFrames/TextDesigner/Preview.lua similarity index 100% rename from TextDesigner/Preview.lua rename to DandersFrames/TextDesigner/Preview.lua diff --git a/TextDesigner/Render.lua b/DandersFrames/TextDesigner/Render.lua similarity index 100% rename from TextDesigner/Render.lua rename to DandersFrames/TextDesigner/Render.lua diff --git a/TextDesigner/Resolver.lua b/DandersFrames/TextDesigner/Resolver.lua similarity index 100% rename from TextDesigner/Resolver.lua rename to DandersFrames/TextDesigner/Resolver.lua diff --git a/TextDesigner/TextDesigner.lua b/DandersFrames/TextDesigner/TextDesigner.lua similarity index 100% rename from TextDesigner/TextDesigner.lua rename to DandersFrames/TextDesigner/TextDesigner.lua diff --git a/TextDesigner/UI/Options.lua b/DandersFrames/TextDesigner/UI/Options.lua similarity index 100% rename from TextDesigner/UI/Options.lua rename to DandersFrames/TextDesigner/UI/Options.lua diff --git a/Textures/Wizards/overlay_disabled.tga b/DandersFrames/Textures/Wizards/overlay_disabled.tga similarity index 100% rename from Textures/Wizards/overlay_disabled.tga rename to DandersFrames/Textures/Wizards/overlay_disabled.tga diff --git a/Textures/Wizards/overlay_enabled.tga b/DandersFrames/Textures/Wizards/overlay_enabled.tga similarity index 100% rename from Textures/Wizards/overlay_enabled.tga rename to DandersFrames/Textures/Wizards/overlay_enabled.tga diff --git a/Textures/Wizards/overlay_warning.tga b/DandersFrames/Textures/Wizards/overlay_warning.tga similarity index 100% rename from Textures/Wizards/overlay_warning.tga rename to DandersFrames/Textures/Wizards/overlay_warning.tga diff --git a/generate_changelog.sh b/generate_changelog.sh index ee23c16b..6ca77968 100755 --- a/generate_changelog.sh +++ b/generate_changelog.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# generate_changelog.sh — Reads CHANGELOG.md and generates Core/Changelog.lua +# generate_changelog.sh — Reads CHANGELOG.md and generates the addon Changelog.lua # Called by GitHub Actions before packaging. set -euo pipefail @@ -7,7 +7,7 @@ set -euo pipefail CHANGELOG_FILE="CHANGELOG.md" # Core/ since the 2026-07 folder reorg. release.yml greps this same path for the # release channel and skips builds that only touch it — keep all three in step. -OUTPUT_FILE="Core/Changelog.lua" +OUTPUT_FILE="DandersFrames/Core/Changelog.lua" if [ ! -f "$CHANGELOG_FILE" ]; then echo "Error: $CHANGELOG_FILE not found." @@ -157,7 +157,7 @@ fi echo "$CHANGELOG_CONTENT" > "$CHANGELOG_FILE" # Sync TOC version if it doesn't already match (avoids double-bump when manually updated) -TOC_FILE="DandersFrames.toc" +TOC_FILE="DandersFrames/DandersFrames.toc" CURRENT_TOC_VERSION=$(grep '^## Version:' "$TOC_FILE" | sed 's/^## Version: //') if [ "$CURRENT_TOC_VERSION" != "$VERSION" ]; then sed -i "s/^## Version: .*/## Version: ${VERSION}/" "$TOC_FILE" From 661edefa929389ec12fe12ec001ebec9aab35550 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 19:28:38 +0100 Subject: [PATCH 14/34] README: document the container layout and junction setup The repo root is now a container holding the addon folders side by side, so a clone can no longer live inside Interface/AddOns. Records the one-time junction setup both developers need, and why junctions (/J) rather than symlinks (/D) -- no admin rights or Developer Mode required. README.md is in .pkgmeta's ignore list, so this is repo-facing only and never reaches the packaged addon. --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index f13a2b15..2aef9cff 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,52 @@ This addon bundles the following third-party libraries, each under their own res - LibSerialize - LibSharedMedia-3.0 +## Development setup + +This repo is a **container**, not an addon folder. Its root holds the addon +folders side by side, the way ElvUI and Grid2 do: + +``` +/ + DandersFrames/ the addon + DandersFrames_Options/ load-on-demand companion (settings, editors, debug) + README.md CHANGELOG.md .pkgmeta .github/ Tools/ +``` + +WoW only loads addons from `Interface/AddOns//.toc`, so the repo +cannot live inside `AddOns/` any more. Clone it anywhere and link the addon +folders in with **directory junctions** — junctions (`/J`), not symlinks +(`/D`), so no admin rights or Developer Mode are needed. + +### One-time, per game install + +From your `Interface/AddOns` folder, with WoW closed: + +``` +mklink /J "DandersFrames" "C:\path\to\repo\DandersFrames" +mklink /J "DandersFrames_Options" "C:\path\to\repo\DandersFrames_Options" +``` + +Delete any real `AddOns/DandersFrames` folder first — a junction cannot +replace an existing directory. Repeat per install (`_retail_`, `_ptr_`). + +After that, edit in the repo and `/reload` in game; the junction means there +is no copy or sync step. `git status`, branches and PRs are unchanged: one +repo, one branch, one PR, even for a change spanning both folders. + +### Without the junctions + +WoW loads whatever it finds. If only `DandersFrames` is linked you get the +frames but no settings panel; if neither is, the addon simply is not there. +Nothing silently half-works. + +### Verification tooling + +`docs/reorg-tools/` holds the checkers used during the restructure — TOC +completeness, load order, split-plumbing aliases, and the load-on-demand +boundary. They default to `/DandersFrames`, so they run with no +arguments from the repo root. `docs/` is gitignored; the tools are local. + ## License All rights reserved. From d97ecfa073c3a25bad5c62432e9122232381ec7b Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 19:34:20 +0100 Subject: [PATCH 15/34] README: describe the layout that exists, not the planned one The setup section documented DandersFrames_Options as if it were present and told the reader to junction it. It does not exist yet -- the second mklink would have failed on a missing target, which is a poor first experience for anyone following the doc. Now describes one addon folder and one junction, with a short section on why the container layout exists at all (the companion needs a sibling top-level folder, which is only possible if the repo root stops being the addon) and the measured saving that justifies it. States plainly that the companion is not built yet. --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2aef9cff..73eea861 100644 --- a/README.md +++ b/README.md @@ -51,18 +51,17 @@ This addon bundles the following third-party libraries, each under their own res ## Development setup This repo is a **container**, not an addon folder. Its root holds the addon -folders side by side, the way ElvUI and Grid2 do: +folder rather than being it, the way ElvUI and Grid2 are laid out: ``` / DandersFrames/ the addon - DandersFrames_Options/ load-on-demand companion (settings, editors, debug) README.md CHANGELOG.md .pkgmeta .github/ Tools/ ``` WoW only loads addons from `Interface/AddOns//.toc`, so the repo cannot live inside `AddOns/` any more. Clone it anywhere and link the addon -folders in with **directory junctions** — junctions (`/J`), not symlinks +folder in with a **directory junction** — a junction (`/J`), not a symlink (`/D`), so no admin rights or Developer Mode are needed. ### One-time, per game install @@ -70,22 +69,31 @@ folders in with **directory junctions** — junctions (`/J`), not symlinks From your `Interface/AddOns` folder, with WoW closed: ``` -mklink /J "DandersFrames" "C:\path\to\repo\DandersFrames" -mklink /J "DandersFrames_Options" "C:\path\to\repo\DandersFrames_Options" +mklink /J "DandersFrames" "C:\path\to\repo\DandersFrames" ``` Delete any real `AddOns/DandersFrames` folder first — a junction cannot replace an existing directory. Repeat per install (`_retail_`, `_ptr_`). After that, edit in the repo and `/reload` in game; the junction means there -is no copy or sync step. `git status`, branches and PRs are unchanged: one -repo, one branch, one PR, even for a change spanning both folders. +is no copy or sync step. `git status`, branches and PRs are unchanged — one +repo, one branch, one PR. -### Without the junctions +### Why a container, when there is only one addon folder -WoW loads whatever it finds. If only `DandersFrames` is linked you get the -frames but no settings panel; if neither is, the addon simply is not there. -Nothing silently half-works. +Groundwork for a load-on-demand companion, `DandersFrames_Options`, holding +the settings panel, the designers and the debug tools. WoW resolves +`LoadAddOn("Name")` to `AddOns/Name/Name.toc`, so a companion has to be a +top-level addon folder — a sibling of `DandersFrames`, not a subfolder. That +is only possible if the repo root is a container, hence this layout. + +Measured on the PTR build: with that payload not loaded, DandersFrames uses +**8,957 KB instead of 12,348 KB — 3,391 KB less, 27.5%** — and ~55,000 lines +are never parsed at login. The saving lasts until the settings panel is +opened; the login cost is avoided every time. + +**The companion does not exist yet.** When it does, it gets a second junction +alongside the first, and this section will say so. ### Verification tooling From ff3a023e245f35c1996770278fa5055eb11a7f5c Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:13:54 +0100 Subject: [PATCH 16/34] Add the DandersFrames_Options load-on-demand companion 30 files, 55,096 lines -- the settings panel, both designers, the click-casting UI, test mode and the debug tools -- move out of the main addon into a LoadOnDemand companion. All 30 are zero-change renames. Measured on the PTR build with this payload not loaded: 8,957 KB against a 12,348 KB baseline, so 3,391 KB less, 27.5% of the resident footprint. About 55,000 lines are also never parsed at any login. The memory saving lasts until the panel is opened; the parse saving happens every login. Main addon TOC: 121 entries -> 91. .pkgmeta is resolved. move-folders keys are relative to the working directory and include the package name as the root, values are relative to the package root, so: move-folders: DandersFrames/DandersFrames: DandersFrames DandersFrames/DandersFrames_Options: DandersFrames_Options The first line looks like it collides with its own parent and does not -- this is the documented shape for a container repo that ships more than one folder. I had talked myself out of it on the assumption it would collide; reading a working example settled it. GUI/LoadOptions.lua is the only thing that loads the companion. DF:ToggleGUI becomes a stub there that loads it and re-dispatches to the real function, which the companion installs over the top. Three ways it can fail and all three report rather than doing nothing: the companion missing, disabled in the AddOns list, or loading without providing a panel. The last is caught by comparing DF.ToggleGUI against the stub itself -- without that check a re-dispatch would recurse until the stack blew. A settings panel that silently refuses to open is the failure mode this whole branch has been trying to design out. Local dev needs a second junction alongside the first: mklink /J "DandersFrames_Options" "\DandersFrames_Options" Users need nothing: the packager ships both as sibling folders. Verified: every entry in both TOCs exists on disk, resident load order has 0 violations and 0 unresolved reads with the payload gone, and the loader plus sampled companion files parse. --- .pkgmeta | 40 ++++------ DandersFrames/DandersFrames.toc | 30 -------- DandersFrames/GUI/LoadOptions.lua | 73 ++++++++++++++++++ .../AuraDesigner/UI/Cards.lua | 0 .../AuraDesigner/UI/Editor.lua | 0 .../AuraDesigner/UI/Groups.lua | 0 .../AuraDesigner/UI/Indicators.lua | 0 .../AuraDesigner/UI/Options.lua | 0 .../ClickCasting/UI/BindingEditor.lua | 0 .../ClickCasting/UI/Dialogs.lua | 0 .../ClickCasting/UI/Main.lua | 0 .../ClickCasting/UI/ProfilesPanel.lua | 0 .../DandersFrames_Options.toc | 77 +++++++++++++++++++ .../Debug/AtlasBrowser.lua | 0 .../Debug/AttachmentScan.lua | 0 .../Debug/AuraExplorer.lua | 0 .../Debug/MemoryTest.lua | 0 .../FilterRegistry/UI/Options.lua | 0 .../FilterRegistry/UI/SpellPicker.lua | 0 .../GUI/ColorPicker.lua | 0 .../GUI/Controls.lua | 0 .../GUI/Pages/Auras.lua | 0 .../GUI/Pages/AutoProfiles.lua | 0 .../GUI/Pages/Frames.lua | 0 .../GUI/Pages/Indicators.lua | 0 .../GUI/Pages/Modules.lua | 0 .../GUI/Pages/NicknamesPage.lua | 0 .../GUI/Pages/NicknamesPicker.lua | 0 .../GUI/Pages/Options.lua | 0 .../GUI/Panel.lua | 0 .../GUI/Sections.lua | 0 .../TestMode/TestFramePool.lua | 0 .../TestMode/TestMode.lua | 0 .../TextDesigner/UI/Options.lua | 0 34 files changed, 167 insertions(+), 53 deletions(-) create mode 100644 DandersFrames/GUI/LoadOptions.lua rename {DandersFrames => DandersFrames_Options}/AuraDesigner/UI/Cards.lua (100%) rename {DandersFrames => DandersFrames_Options}/AuraDesigner/UI/Editor.lua (100%) rename {DandersFrames => DandersFrames_Options}/AuraDesigner/UI/Groups.lua (100%) rename {DandersFrames => DandersFrames_Options}/AuraDesigner/UI/Indicators.lua (100%) rename {DandersFrames => DandersFrames_Options}/AuraDesigner/UI/Options.lua (100%) rename {DandersFrames => DandersFrames_Options}/ClickCasting/UI/BindingEditor.lua (100%) rename {DandersFrames => DandersFrames_Options}/ClickCasting/UI/Dialogs.lua (100%) rename {DandersFrames => DandersFrames_Options}/ClickCasting/UI/Main.lua (100%) rename {DandersFrames => DandersFrames_Options}/ClickCasting/UI/ProfilesPanel.lua (100%) create mode 100644 DandersFrames_Options/DandersFrames_Options.toc rename {DandersFrames => DandersFrames_Options}/Debug/AtlasBrowser.lua (100%) rename {DandersFrames => DandersFrames_Options}/Debug/AttachmentScan.lua (100%) rename {DandersFrames => DandersFrames_Options}/Debug/AuraExplorer.lua (100%) rename {DandersFrames => DandersFrames_Options}/Debug/MemoryTest.lua (100%) rename {DandersFrames => DandersFrames_Options}/FilterRegistry/UI/Options.lua (100%) rename {DandersFrames => DandersFrames_Options}/FilterRegistry/UI/SpellPicker.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/ColorPicker.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Controls.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/Auras.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/AutoProfiles.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/Frames.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/Indicators.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/Modules.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/NicknamesPage.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/NicknamesPicker.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Pages/Options.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Panel.lua (100%) rename {DandersFrames => DandersFrames_Options}/GUI/Sections.lua (100%) rename {DandersFrames => DandersFrames_Options}/TestMode/TestFramePool.lua (100%) rename {DandersFrames => DandersFrames_Options}/TestMode/TestMode.lua (100%) rename {DandersFrames => DandersFrames_Options}/TextDesigner/UI/Options.lua (100%) diff --git a/.pkgmeta b/.pkgmeta index 1c5c68e6..bc67fed2 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -1,29 +1,23 @@ -# ☠ UNRESOLVED — THIS FILE IS STILL WRITTEN FOR THE OLD SINGLE-FOLDER LAYOUT -# and will NOT package the container layout correctly. Do not cut a release -# until it is fixed and verified. -# -# The repo root now holds DandersFrames/ (and later DandersFrames_Options/), -# so `package-as: DandersFrames` would produce .release/DandersFrames/ with the -# real addon nested one level too deep at .release/DandersFrames/DandersFrames/. -# -# `move-folders` is the mechanism, but the exact semantics matter and I could -# not confirm them offline: whether its keys are relative to .release or to the -# package directory decides whether the obvious config collides with itself -# (moving .release/DandersFrames/DandersFrames onto .release/DandersFrames). -# -# HOW TO SETTLE IT — do not reason about it, measure it: -# 1. Push a throwaway tag (e.g. v0.0.0-pkgtest) on a scratch branch. -# 2. Download the produced zip and look at its top level. -# 3. It must contain DandersFrames/ and DandersFrames_Options/ as SIBLINGS, -# each with its own .toc at its root. -# 4. Delete the tag and the release. -# Checking a comparable multi-folder addon's own .pkgmeta would also answer it. -# -# ⚠ Also verify: release.yml's locale validation globs .release/DandersFrames/ -# Locales/*.lua -- that path may shift with whatever fix lands here. package-as: DandersFrames enable-nolib-creation: no +# The repo root is a container: it holds the addon folders rather than being +# one. The packager copies the checkout into .release//, so both +# land a level too deep; move-folders lifts them back out to the zip root. +# +# move-folders keys are relative to the working directory and INCLUDE the +# package name as the root; values are relative to the package root. So +# "DandersFrames/DandersFrames: DandersFrames" reads as "the DandersFrames +# folder inside the package dir becomes the top-level DandersFrames" -- it +# looks like it collides with its own parent and does not. This is the same +# shape ElvUI ships (ElvUI/ElvUI: ElvUI, ElvUI/ElvUI_Options: ElvUI_Options). +# +# Everything at the repo root that is not an addon folder is stripped by the +# ignore list below, so the zip contains only the two addon folders. +move-folders: + DandersFrames/DandersFrames: DandersFrames + DandersFrames/DandersFrames_Options: DandersFrames_Options + ignore: - CHANGELOG.md - README.md diff --git a/DandersFrames/DandersFrames.toc b/DandersFrames/DandersFrames.toc index 2102aa42..82737ecf 100644 --- a/DandersFrames/DandersFrames.toc +++ b/DandersFrames/DandersFrames.toc @@ -68,7 +68,6 @@ Debug\DebugConsole.lua Debug\Profiler.lua # Foreign-attachment scan (/df attached) — diagnoses other addons anchored to our frames -Debug\AttachmentScan.lua # GUI System GUI\IconLib.lua @@ -76,25 +75,13 @@ GUI\IconLib.lua # creates the GUI table plus GUI._state / GUI._priv, which every later part # re-declares aliases from. GUI\GUI.lua -GUI\Sections.lua GUI\Widgets.lua -GUI\Controls.lua -GUI\Panel.lua GUI\DFFonts.lua -GUI\ColorPicker.lua Features\Search.lua # Settings pages. DF:SetupGUIPages was one 9,673-line function; it is now a # chain -- Options.lua calls part 2 at its end, part 2 calls part 3, and so on. # Order is load-bearing: a later part must be defined before the earlier one # runs, and all five must load before anything opens the panel. -GUI\Pages\Options.lua -GUI\Pages\Frames.lua -GUI\Pages\Auras.lua -GUI\Pages\Indicators.lua -GUI\Pages\Modules.lua -GUI\Pages\AutoProfiles.lua -GUI\Pages\NicknamesPage.lua -GUI\Pages\NicknamesPicker.lua # Popup System (alert / confirm / input dialogs) GUI\Popup.lua @@ -121,8 +108,6 @@ Frames\Pets.lua # live rendering calls into test mode, so the entry points must always exist. # ☠ It MUST load before TestMode.lua, which overwrites every stub. TestMode\Shim.lua -TestMode\TestFramePool.lua -TestMode\TestMode.lua # Features Features\Auras.lua @@ -150,11 +135,6 @@ AuraDesigner\Factory.lua AuraDesigner\SoundEngine.lua # Editor UI, split from one 9k-line file. Order is load-bearing: Options.lua # creates DF.AuraDesigner._uiState / ._priv, which every later part aliases. -AuraDesigner\UI\Options.lua -AuraDesigner\UI\Groups.lua -AuraDesigner\UI\Indicators.lua -AuraDesigner\UI\Cards.lua -AuraDesigner\UI\Editor.lua # Text Designer TextDesigner\TextDesigner.lua @@ -164,13 +144,10 @@ TextDesigner\Resolver.lua TextDesigner\Render.lua TextDesigner\Migration.lua TextDesigner\Preview.lua -TextDesigner\UI\Options.lua # Filter Registry FilterRegistry\SpellDB.lua FilterRegistry\Registry.lua -FilterRegistry\UI\Options.lua -FilterRegistry\UI\SpellPicker.lua # Aura Blacklist AuraBlacklist\Config.lua @@ -183,9 +160,6 @@ Features\PinnedFrames.lua Features\FlatRaidFrames.lua # Debug (Profiler.lua is loaded earlier so its SetScript hook is in place) -Debug\AuraExplorer.lua -Debug\AtlasBrowser.lua -Debug\MemoryTest.lua # Click-Casting System ClickCasting\Core.lua @@ -194,10 +168,6 @@ ClickCasting\Profiles.lua ClickCasting\Frames.lua ClickCasting\Bindings.lua ClickCasting\Events.lua -ClickCasting\UI\Main.lua -ClickCasting\UI\ProfilesPanel.lua -ClickCasting\UI\BindingEditor.lua -ClickCasting\UI\Dialogs.lua # External API (for Wago UI Packs, etc.) Core\API.lua diff --git a/DandersFrames/GUI/LoadOptions.lua b/DandersFrames/GUI/LoadOptions.lua new file mode 100644 index 00000000..0b80ea63 --- /dev/null +++ b/DandersFrames/GUI/LoadOptions.lua @@ -0,0 +1,73 @@ +local addonName, DF = ... + +-- ============================================================ +-- OPTIONS LOADER — always loaded +-- ============================================================ +-- The settings panel, both designers, the click-casting UI, test mode and the +-- debug tools live in DandersFrames_Options, a LoadOnDemand companion addon. +-- Nothing loads it automatically; this file is the single place that does. +-- +-- While it stays unloaded DandersFrames uses 3,391 KB less (8,957 vs 12,348, +-- measured on the PTR build) and ~55,000 lines are never parsed at login. +-- +-- ☠ The companion can be MISSING, DISABLED or STALE. A player can untick it in +-- the addon list, or install the zip incompletely. Every path below reports +-- that plainly rather than doing nothing: a settings panel that silently +-- refuses to open is the worst outcome, and is exactly the silent-skip failure +-- the migrations move was made to avoid. + +local OPTIONS_ADDON = "DandersFrames_Options" + +local LoadAddOn = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn +local GetAddOnEnableState = C_AddOns and C_AddOns.GetAddOnEnableState +local DoesAddOnExist = C_AddOns and C_AddOns.DoesAddOnExist + +-- Human-readable reason the companion is not usable, or nil when it is fine. +local function UnavailableReason() + if DoesAddOnExist and not DoesAddOnExist(OPTIONS_ADDON) then + return "not installed" + end + if GetAddOnEnableState and GetAddOnEnableState(OPTIONS_ADDON, nil) == 0 then + return "disabled in the AddOns list" + end + return nil +end + +-- Load the companion, returning true once its files are in memory. +-- Safe to call repeatedly: LoadAddOn is a no-op once loaded. +function DF:EnsureOptionsLoaded() + if DF._optionsAddonLoaded then return true end + + local why = UnavailableReason() + if why then + DF:Err(("Settings are unavailable: %s is %s."):format(OPTIONS_ADDON, why)) + return false + end + + local loaded, reason = LoadAddOn(OPTIONS_ADDON) + if not loaded then + DF:Err(("Could not load %s (%s)."):format(OPTIONS_ADDON, tostring(reason))) + return false + end + + DF._optionsAddonLoaded = true + return true +end + +-- ☠ This stub is REPLACED by the real DF:ToggleGUI when the companion loads, +-- so the re-dispatch below reaches the real function rather than itself. +-- +-- The identity check is the loop guard. If the companion ever loaded WITHOUT +-- defining ToggleGUI -- a renamed function, a file dropped from its .toc -- +-- DF.ToggleGUI would still be this stub and a plain re-dispatch would recurse +-- until the stack blew. Comparing against the stub catches that and reports it. +local toggleStub +toggleStub = function() + if not DF:EnsureOptionsLoaded() then return end + if DF.ToggleGUI == toggleStub then + DF:Err(("%s loaded but did not provide the settings panel -- its .toc may be incomplete."):format(OPTIONS_ADDON)) + return + end + return DF:ToggleGUI() +end +DF.ToggleGUI = toggleStub diff --git a/DandersFrames/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua similarity index 100% rename from DandersFrames/AuraDesigner/UI/Cards.lua rename to DandersFrames_Options/AuraDesigner/UI/Cards.lua diff --git a/DandersFrames/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua similarity index 100% rename from DandersFrames/AuraDesigner/UI/Editor.lua rename to DandersFrames_Options/AuraDesigner/UI/Editor.lua diff --git a/DandersFrames/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua similarity index 100% rename from DandersFrames/AuraDesigner/UI/Groups.lua rename to DandersFrames_Options/AuraDesigner/UI/Groups.lua diff --git a/DandersFrames/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua similarity index 100% rename from DandersFrames/AuraDesigner/UI/Indicators.lua rename to DandersFrames_Options/AuraDesigner/UI/Indicators.lua diff --git a/DandersFrames/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua similarity index 100% rename from DandersFrames/AuraDesigner/UI/Options.lua rename to DandersFrames_Options/AuraDesigner/UI/Options.lua diff --git a/DandersFrames/ClickCasting/UI/BindingEditor.lua b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua similarity index 100% rename from DandersFrames/ClickCasting/UI/BindingEditor.lua rename to DandersFrames_Options/ClickCasting/UI/BindingEditor.lua diff --git a/DandersFrames/ClickCasting/UI/Dialogs.lua b/DandersFrames_Options/ClickCasting/UI/Dialogs.lua similarity index 100% rename from DandersFrames/ClickCasting/UI/Dialogs.lua rename to DandersFrames_Options/ClickCasting/UI/Dialogs.lua diff --git a/DandersFrames/ClickCasting/UI/Main.lua b/DandersFrames_Options/ClickCasting/UI/Main.lua similarity index 100% rename from DandersFrames/ClickCasting/UI/Main.lua rename to DandersFrames_Options/ClickCasting/UI/Main.lua diff --git a/DandersFrames/ClickCasting/UI/ProfilesPanel.lua b/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua similarity index 100% rename from DandersFrames/ClickCasting/UI/ProfilesPanel.lua rename to DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua diff --git a/DandersFrames_Options/DandersFrames_Options.toc b/DandersFrames_Options/DandersFrames_Options.toc new file mode 100644 index 00000000..b9e00a07 --- /dev/null +++ b/DandersFrames_Options/DandersFrames_Options.toc @@ -0,0 +1,77 @@ +## Interface: 120100 +## Title: DandersFrames |cff9482c9Options|r +## Notes: Settings panel, editors and debug tools for DandersFrames. Loads on demand. +## Author: Danders +## Version: v5.0.0-alpha.13 +## IconTexture: Interface\AddOns\DandersFrames\Media\DF_Icon +## LoadOnDemand: 1 +## RequiredDeps: DandersFrames +## AllowAddOnTableAccess: 1 + +# ============================================================ +# DandersFrames Options -- load-on-demand companion +# ============================================================ +# Everything the settings panel needs and the frames do not. Not loaded at +# login; DandersFrames loads it the first time you open /df. +# +# Measured on the PTR build: while this stays unloaded DandersFrames uses +# 8,957 KB instead of 12,348 KB -- 3,391 KB less, 27.5% -- and ~55,000 lines +# are never parsed at any login. The memory saving lasts until the panel is +# opened; the parse saving happens every login regardless. +# +# RequiredDeps plus the matching name prefix makes the addon list show this +# as a collapsible child of DandersFrames rather than a separate entry. +# +# ☠ ORDER IS LOAD-BEARING, exactly as in the main .toc. These files were split +# out of three monoliths and each group has a first file that creates the +# shared state every later part aliases: +# GUI/Sections -> Controls -> Panel (GUI.lua stays resident) +# AuraDesigner/UI/Options first (creates _uiState and _priv) +# GUI/Pages/Options first (heads the page chain) + +# GUI parts used only by settings pages +GUI\Sections.lua +GUI\Controls.lua +GUI\Panel.lua +GUI\ColorPicker.lua + +# Settings pages -- Options.lua heads the chain, the rest follow in order +GUI\Pages\Options.lua +GUI\Pages\Frames.lua +GUI\Pages\Auras.lua +GUI\Pages\Indicators.lua +GUI\Pages\Modules.lua +GUI\Pages\AutoProfiles.lua +GUI\Pages\NicknamesPage.lua +GUI\Pages\NicknamesPicker.lua + +# Aura Designer editor (migrations stay resident in the main addon) +AuraDesigner\UI\Options.lua +AuraDesigner\UI\Groups.lua +AuraDesigner\UI\Indicators.lua +AuraDesigner\UI\Cards.lua +AuraDesigner\UI\Editor.lua + +# Text Designer editor +TextDesigner\UI\Options.lua + +# Filter Registry UI +FilterRegistry\UI\Options.lua +FilterRegistry\UI\SpellPicker.lua + +# Click-Casting UI +ClickCasting\UI\Main.lua +ClickCasting\UI\ProfilesPanel.lua +ClickCasting\UI\BindingEditor.lua +ClickCasting\UI\Dialogs.lua + +# Test mode -- TestMode\Shim.lua stays resident so live rendering always has +# the entry points; these overwrite the stubs when this addon loads. +TestMode\TestFramePool.lua +TestMode\TestMode.lua + +# Debug tools (DebugConsole and Profiler stay resident in the main addon) +Debug\AuraExplorer.lua +Debug\AtlasBrowser.lua +Debug\MemoryTest.lua +Debug\AttachmentScan.lua diff --git a/DandersFrames/Debug/AtlasBrowser.lua b/DandersFrames_Options/Debug/AtlasBrowser.lua similarity index 100% rename from DandersFrames/Debug/AtlasBrowser.lua rename to DandersFrames_Options/Debug/AtlasBrowser.lua diff --git a/DandersFrames/Debug/AttachmentScan.lua b/DandersFrames_Options/Debug/AttachmentScan.lua similarity index 100% rename from DandersFrames/Debug/AttachmentScan.lua rename to DandersFrames_Options/Debug/AttachmentScan.lua diff --git a/DandersFrames/Debug/AuraExplorer.lua b/DandersFrames_Options/Debug/AuraExplorer.lua similarity index 100% rename from DandersFrames/Debug/AuraExplorer.lua rename to DandersFrames_Options/Debug/AuraExplorer.lua diff --git a/DandersFrames/Debug/MemoryTest.lua b/DandersFrames_Options/Debug/MemoryTest.lua similarity index 100% rename from DandersFrames/Debug/MemoryTest.lua rename to DandersFrames_Options/Debug/MemoryTest.lua diff --git a/DandersFrames/FilterRegistry/UI/Options.lua b/DandersFrames_Options/FilterRegistry/UI/Options.lua similarity index 100% rename from DandersFrames/FilterRegistry/UI/Options.lua rename to DandersFrames_Options/FilterRegistry/UI/Options.lua diff --git a/DandersFrames/FilterRegistry/UI/SpellPicker.lua b/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua similarity index 100% rename from DandersFrames/FilterRegistry/UI/SpellPicker.lua rename to DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua diff --git a/DandersFrames/GUI/ColorPicker.lua b/DandersFrames_Options/GUI/ColorPicker.lua similarity index 100% rename from DandersFrames/GUI/ColorPicker.lua rename to DandersFrames_Options/GUI/ColorPicker.lua diff --git a/DandersFrames/GUI/Controls.lua b/DandersFrames_Options/GUI/Controls.lua similarity index 100% rename from DandersFrames/GUI/Controls.lua rename to DandersFrames_Options/GUI/Controls.lua diff --git a/DandersFrames/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua similarity index 100% rename from DandersFrames/GUI/Pages/Auras.lua rename to DandersFrames_Options/GUI/Pages/Auras.lua diff --git a/DandersFrames/GUI/Pages/AutoProfiles.lua b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua similarity index 100% rename from DandersFrames/GUI/Pages/AutoProfiles.lua rename to DandersFrames_Options/GUI/Pages/AutoProfiles.lua diff --git a/DandersFrames/GUI/Pages/Frames.lua b/DandersFrames_Options/GUI/Pages/Frames.lua similarity index 100% rename from DandersFrames/GUI/Pages/Frames.lua rename to DandersFrames_Options/GUI/Pages/Frames.lua diff --git a/DandersFrames/GUI/Pages/Indicators.lua b/DandersFrames_Options/GUI/Pages/Indicators.lua similarity index 100% rename from DandersFrames/GUI/Pages/Indicators.lua rename to DandersFrames_Options/GUI/Pages/Indicators.lua diff --git a/DandersFrames/GUI/Pages/Modules.lua b/DandersFrames_Options/GUI/Pages/Modules.lua similarity index 100% rename from DandersFrames/GUI/Pages/Modules.lua rename to DandersFrames_Options/GUI/Pages/Modules.lua diff --git a/DandersFrames/GUI/Pages/NicknamesPage.lua b/DandersFrames_Options/GUI/Pages/NicknamesPage.lua similarity index 100% rename from DandersFrames/GUI/Pages/NicknamesPage.lua rename to DandersFrames_Options/GUI/Pages/NicknamesPage.lua diff --git a/DandersFrames/GUI/Pages/NicknamesPicker.lua b/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua similarity index 100% rename from DandersFrames/GUI/Pages/NicknamesPicker.lua rename to DandersFrames_Options/GUI/Pages/NicknamesPicker.lua diff --git a/DandersFrames/GUI/Pages/Options.lua b/DandersFrames_Options/GUI/Pages/Options.lua similarity index 100% rename from DandersFrames/GUI/Pages/Options.lua rename to DandersFrames_Options/GUI/Pages/Options.lua diff --git a/DandersFrames/GUI/Panel.lua b/DandersFrames_Options/GUI/Panel.lua similarity index 100% rename from DandersFrames/GUI/Panel.lua rename to DandersFrames_Options/GUI/Panel.lua diff --git a/DandersFrames/GUI/Sections.lua b/DandersFrames_Options/GUI/Sections.lua similarity index 100% rename from DandersFrames/GUI/Sections.lua rename to DandersFrames_Options/GUI/Sections.lua diff --git a/DandersFrames/TestMode/TestFramePool.lua b/DandersFrames_Options/TestMode/TestFramePool.lua similarity index 100% rename from DandersFrames/TestMode/TestFramePool.lua rename to DandersFrames_Options/TestMode/TestFramePool.lua diff --git a/DandersFrames/TestMode/TestMode.lua b/DandersFrames_Options/TestMode/TestMode.lua similarity index 100% rename from DandersFrames/TestMode/TestMode.lua rename to DandersFrames_Options/TestMode/TestMode.lua diff --git a/DandersFrames/TextDesigner/UI/Options.lua b/DandersFrames_Options/TextDesigner/UI/Options.lua similarity index 100% rename from DandersFrames/TextDesigner/UI/Options.lua rename to DandersFrames_Options/TextDesigner/UI/Options.lua From 58a8c8b93803649606c2cb9547337524270822f7 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:17:48 +0100 Subject: [PATCH 17/34] Add the missing GUI/LoadOptions.lua TOC entry The loader was written and committed but never listed in the .toc -- a Python replace hit an escape-sequence warning and silently did not match, so the edit was a no-op while everything around it succeeded. Without the entry the file never loads, DF:ToggleGUI stays nil and /df errors on the first keypress. Caught by toc_check.py ("on disk but not in TOC"), which is exactly the silent-failure class that check exists for. Also drops two stale comment blocks left behind when the settings pages moved to the companion. --- DandersFrames/DandersFrames.toc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DandersFrames/DandersFrames.toc b/DandersFrames/DandersFrames.toc index 82737ecf..bed95e5c 100644 --- a/DandersFrames/DandersFrames.toc +++ b/DandersFrames/DandersFrames.toc @@ -77,11 +77,11 @@ GUI\IconLib.lua GUI\GUI.lua GUI\Widgets.lua GUI\DFFonts.lua +# Loads DandersFrames_Options on demand and owns the DF:ToggleGUI stub. The +# settings panel, both designers, the click-casting UI, test mode and the debug +# tools all live in that companion now. +GUI\LoadOptions.lua Features\Search.lua -# Settings pages. DF:SetupGUIPages was one 9,673-line function; it is now a -# chain -- Options.lua calls part 2 at its end, part 2 calls part 3, and so on. -# Order is load-bearing: a later part must be defined before the earlier one -# runs, and all five must load before anything opens the panel. # Popup System (alert / confirm / input dialogs) GUI\Popup.lua From b8e32f0833751521f69ad6df7ba807468e6161ab Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:20:02 +0100 Subject: [PATCH 18/34] Fix a resident file caching a companion-owned table at load GUI/Widgets.lua is resident but aliased INFO_BANNER_TONES at file scope, and that table is defined in GUI/Sections.lua -- which now lives in the load-on-demand companion. At login GUI._priv.INFO_BANNER_TONES is nil, so the alias captured nil permanently and never saw the companion's later publish. GUI:CreateDebugCategoryRow would then nil-index it. Read at call time instead. That function is only ever called from a settings page, so the companion is loaded by the time it runs. This is a new failure mode the earlier splits could not have: a load-time alias is fine across files that always load together, and wrong the moment one side becomes load-on-demand. Found by alias_check.py, but only after repointing it -- it and lod_gate_check.py still had paths from before the payload moved, so both were silently passing on nothing. Both now resolve across the two addon folders, and alias_check gained the settings-page chain as a third split set. --- DandersFrames/GUI/Widgets.lua | 9003 +++++++++++++++++---------------- 1 file changed, 4504 insertions(+), 4499 deletions(-) diff --git a/DandersFrames/GUI/Widgets.lua b/DandersFrames/GUI/Widgets.lua index 8f9664e6..b5202e62 100644 --- a/DandersFrames/GUI/Widgets.lua +++ b/DandersFrames/GUI/Widgets.lua @@ -16,4503 +16,4508 @@ local SnapHeightEven = GUI.SnapHeightEven local CreateElementBackdrop = GUI._priv.CreateElementBackdrop local CreatePanelBackdrop = GUI._priv.CreatePanelBackdrop local StyleScrollBar = GUI.StyleScrollBar -local INFO_BANNER_TONES = GUI._priv.INFO_BANNER_TONES - --- Counterpart to ShowTooltip: hide the shared GameTooltip. Wrapped so callers --- route through GUI instead of poking GameTooltip directly. -function GUI:HideTooltip() - GameTooltip:Hide() -end - --- ============================================================ --- ATTACHING a tooltip to a settings widget — ONE way, for every factory. --- --- Three factories used to each have their own idea, and on one page the same --- gesture did three different things: --- checkbox .tooltip hover the container ANCHOR_CURSOR --- dropdown .tooltip hover the BUTTON ANCHOR_CURSOR --- slider .tooltipText hover the SLIDER ANCHOR_RIGHT --- Worse, on a dropdown and a slider the LABEL sits above the control, outside --- its hit rect, so hovering the words never did anything — while on a checkbox --- (label beside the box, inside the container) it did. Five more factories — --- colour picker, font / texture dropdown, input, growth control — had no --- tooltip support at all, so ~136 controls could not carry one. --- --- The rule now: THE HIT AREA IS THE LABEL, and only the label. --- --- ⚠ This is deliberately NOT the whole widget. The first version of this hovered --- the control too, which is the obvious reading of "make the label work" — but a --- cursor-anchored tooltip then sits on top of the slider or dropdown you are --- trying to read and operate, and no amount of offsetting it fully solves that, --- because the thing you point at IS the thing being covered. Krathe's call, --- 2026-07-27, after trying both. Reading and adjusting are separate gestures: --- point at the words to find out what it does, point at the control to use it. --- --- The label is a FontString and cannot take mouse input, so each widget gets one --- invisible frame sized to the label's own rect. That also handles a label wider --- than its container for free (the checkbox case) — the frame follows the TEXT, --- not the box, so there is no hit-rect arithmetic to keep in sync. --- --- The anchor is whatever ShowTooltip defaults to — set in ONE place so no page --- can drift. Nothing here passes an anchor, deliberately. --- --- The spec is read AT HOVER TIME, not when it is attached — every call site --- sets it after creation, on the container the factory returned: --- widget.tooltip = "body" title = the widget's own label --- widget.tooltip = { title=, lines=, tone= } the full ShowTooltip shape --- widget.tooltipText / .tooltipSubText legacy pair, still honoured --- ============================================================ -local function ResolveTooltipSpec(widget, label) - local t = widget.tooltip - if type(t) == "table" then - -- Full spec from the caller. Default the title to the label so the - -- common case only has to say what the setting DOES. - if t.title == nil then t.title = label end - return t - end - if type(t) == "string" and t ~= "" then - return { title = label, lines = { t } } - end - -- Legacy pair. Deliberately NOT re-titled from the label: these read as - -- title-then-subtitle by design (the Frame Level explainer, the override - -- markers), and re-titling them would change tooltips that are already - -- correct. New code should use .tooltip. - if widget.tooltipText then - return { - title = widget.tooltipText, - lines = widget.tooltipSubText and { widget.tooltipSubText } or nil, - } - end - return nil -end - --- widget the frame the caller holds and sets .tooltip on (the container) --- label the default title --- labelRegion the label FontString — the hit frame is built over ITS rect -function GUI:AttachTooltip(widget, label, labelRegion) - if not labelRegion then return end - - local hit = CreateFrame("Frame", nil, widget) - -- Two-corner anchored to the FontString, so it tracks the text if the label - -- is ever re-set or re-fonted. The 2px vertical bleed makes a single line of - -- small text comfortable to hit without reaching the control below it. - hit:SetPoint("TOPLEFT", labelRegion, "TOPLEFT", 0, 2) - hit:SetPoint("BOTTOMRIGHT", labelRegion, "BOTTOMRIGHT", 0, -2) - hit:EnableMouse(true) - -- Above the widget's own children so the label area wins the mouse, but it - -- only ever covers the TEXT, so nothing clickable is behind it. - hit:SetFrameLevel((widget:GetFrameLevel() or 0) + 5) - - hit:SetScript("OnEnter", function() - local spec = ResolveTooltipSpec(widget, label) - if spec then GUI:ShowTooltip(hit, spec) end - end) - hit:SetScript("OnLeave", function() GUI:HideTooltip() end) - - widget.dfTooltipHit = hit -- exposed for a caller that needs to re-anchor it - return hit -end - -function GUI:StyleButton(btn, opts) - opts = opts or {} - if opts.width or opts.height then - btn:SetSize(opts.width or btn:GetWidth(), opts.height or btn:GetHeight()) - end - -- Land the height on an EVEN number of device pixels, whether it came from - -- opts or the caller sized the button itself. Buttons are chained with - -- centre-aligning anchors (Copy <- Sync <- Reset, Clicks <- Test <- Unlock), - -- so an odd height puts the whole row on a half pixel -- and since nothing - -- corrects a control's position at runtime, getting the size right at - -- construction is the only thing that keeps their edges crisp. - -- Construction-time and once, so it cannot drive an OnSizeChanged cascade. - local bh = btn:GetHeight() - if bh and bh > 0 then - local sh = SnapHeightEven(btn, bh) - if sh and math.abs(sh - bh) > 1e-4 then btn:SetHeight(sh) end - end - CreateElementBackdrop(btn) -- mixes in BackdropTemplate if needed - - -- Optional label + leading icon. opts.icon = { texture, size (14), - -- color {r,g,b}, gap (4) }. opts.align controls layout: - -- "center" (default) — centre the icon+label as a GROUP (text-only centres - -- the label; icon-only centres the icon). Best for compact buttons whose - -- width ~ their content. - -- "left" — pin the icon at opts.leftPad (12) with the label after it. Best - -- for wide / full-width list-style buttons, where centred content floats - -- in a sea of empty space. - local iconOpt = opts.icon - local iconGap = (iconOpt and iconOpt.gap) or 4 - local iconW = (iconOpt and (iconOpt.size or 18)) or 0 - local hasText = opts.text ~= nil and opts.text ~= "" - local align = opts.align or "center" - local leftPad = opts.leftPad or 12 - -- Toned buttons (danger / success): neutral at rest with an accent-coloured - -- label+icon — soft red for destructive, soft green for affirmative — plus the - -- accent hover wash. A coloured-text button, NOT a filled CTA (the accent set - -- below drives the hover). Mirrors each other so Delete/Save read as a pair. - local toneLabel = (opts.tone == "danger" and { 0.9, 0.45, 0.45 }) - or (opts.tone == "success" and { 0.4, 0.85, 0.5 }) or nil - - if opts.text ~= nil then - if not btn.Text then - btn.Text = btn:CreateFontString(nil, "OVERLAY", opts.font or "DFFontHighlightSmall") - -- Register it as the button's font string so the NATIVE Button:SetText - -- / GetText keep working. Without this, a caller that relabels later - -- (a Start/Stop or Pause/Resume toggle) silently no-ops and the button - -- freezes on its first label -- an easy regression when converting a - -- Blizzard-template button, which always had one. - if btn.SetFontString then btn:SetFontString(btn.Text) end - end - btn.Text:SetText(opts.text) - if toneLabel then - btn.Text:SetTextColor(toneLabel[1], toneLabel[2], toneLabel[3]) - else - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - - if iconOpt then - btn.Icon = btn.Icon or btn:CreateTexture(nil, "OVERLAY") - btn.Icon:SetTexture(iconOpt.texture) - btn.Icon:SetSize(iconW, iconW) - if iconOpt.color then - btn.Icon:SetVertexColor(iconOpt.color.r, iconOpt.color.g, iconOpt.color.b) - elseif toneLabel then - btn.Icon:SetVertexColor(toneLabel[1], toneLabel[2], toneLabel[3]) - end - end - - -- Anchor the icon/label per alignment. - if align == "left" then - if iconOpt then - btn.Icon:ClearAllPoints() - btn.Icon:SetPoint("LEFT", leftPad, 0) - end - if btn.Text then - btn.Text:ClearAllPoints() - if iconOpt then - btn.Text:SetPoint("LEFT", btn.Icon, "RIGHT", iconGap, 0) - else - btn.Text:SetPoint("LEFT", leftPad, 0) - end - end - else - if btn.Text then - btn.Text:ClearAllPoints() - -- Offset right by half the icon+gap so the icon+label GROUP centres. - btn.Text:SetPoint("CENTER", btn, "CENTER", (iconOpt and hasText) and (iconW + iconGap) / 2 or 0, 0) - end - if iconOpt then - btn.Icon:ClearAllPoints() - if hasText then - btn.Icon:SetPoint("RIGHT", btn.Text, "LEFT", -iconGap, 0) - else - btn.Icon:SetPoint("CENTER", btn, "CENTER", 0, 0) - end - end - end - - -- Hover: an accent wash via the native HIGHLIGHT layer (auto-shown on - -- mouseover, like StyleCheckButton / the menu buttons) PLUS a darker accent - -- border for definition. `primary` buttons additionally keep a persistent - -- accent-tinted fill + accent border at rest. Accent = explicit opts.accent - -- (e.g. ClickCasting green) or the mode accent (party purple / raid orange). - local accent = opts.accent - -- tone presets: a destructive "danger" button reuses ALL the accent - -- machinery (hover wash, hover border, primary fill) with a fixed FF4444 red. - -- So a plain danger button is neutral-at-rest with a red hover, and - -- danger+primary is a filled red CTA. Fixed colour ⇒ it won't theme-track - -- (correct — destructive red shouldn't follow the party/raid accent). - if not accent then - if opts.tone == "danger" then - accent = { r = 1, g = 0.27, b = 0.27 } - elseif opts.tone == "success" then - accent = { r = 0.3, g = 0.8, b = 0.45 } - end - end - local primary = opts.primary - local fadeActiveText = opts.fadeActiveText - -- Underline TAB style (opts.tab): the button is transparent (no fill/border) - -- and its active cue is a 2px accent stripe along the bottom + an accent label - -- (dim label when inactive). Driven by SetActive, like a toggle. Distinct from - -- the legacy `isTab` filled-sidebar branch in restBackdrop. - local isTabStyle = opts.tab - -- Ghost action (opts.ghost): transparent like a tab but with no underline — an - -- accent label + faint hover wash. For quiet inline actions (e.g. "+ Add"). - local ghost = opts.ghost - -- Persistent semantic accent (opts.tinted): the accent is meaningful and stays - -- ON at rest — faint accent fill + accent border + accent label — rather than - -- being a neutral button with an accent hover. For role quick-add buttons etc. - -- where the colour IS the button's identity. Pass a fixed opts.accent. - local tinted = opts.tinted - -- Neutral hover (opts.hoverTone = "neutral"): the wash is the plain C_HOVER - -- grey and the border does NOT go accent. For surfaces that are a PLACE - -- rather than an action -- a card header, a list row -- where an accent - -- hover would read as "this is a call to action". Card headers and dropdown - -- rows previously hand-rolled this as an OnEnter/OnLeave SetBackdropColor - -- swap, which duplicated the rest colours at every site. - local neutralHover = opts.hoverTone == "neutral" - -- opts.restBorderColor: let a consumer keep its OWN border identity at rest -- - -- e.g. an Aura/Text group card header tinted by that group's colour -- while - -- fill, hover, selection and disabled all stay shared. Applies ONLY to the - -- neutral rest branch; active / primary / tinted keep their accent-derived - -- borders, so the override can't fight a state the button is in. - local restBorder = opts.restBorderColor - local hl = btn:GetHighlightTexture() or btn:CreateTexture(nil, "HIGHLIGHT") - hl:SetTexture("Interface\\Buttons\\WHITE8x8") - hl:SetAllPoints(btn) - btn.Highlight = hl - - if isTabStyle then - local stripe = btn:CreateTexture(nil, "OVERLAY") - stripe:SetTexture("Interface\\Buttons\\WHITE8x8") - stripe:SetHeight(3) - stripe:SetPoint("BOTTOMLEFT", 0, 0) -- full-width underline (no insets) - stripe:SetPoint("BOTTOMRIGHT", 0, 0) - stripe:Hide() - btn.dfTabStripe = stripe - end - - -- The resting backdrop the button returns to on mouse-out (and that primary - -- buttons also wear permanently): accent-tinted for primary, the active-tab - -- panel colour for active tabs, otherwise the neutral element colour. - local function restBackdrop(self, a) - if isTabStyle then - -- Underline tab: a faint neutral cell when inactive (so every tab's - -- bounds stay visible and the active one doesn't appear to "grow"), - -- and a stronger accent fill when active (a held-hover highlight) - -- beneath its stripe. - if self.dfActive then - self:SetBackdropColor(a.r, a.g, a.b, 0.18) - else - self:SetBackdropColor(1, 1, 1, 0.05) - end - self:SetBackdropBorderColor(0, 0, 0, 0) - return - end - if ghost then - -- Ghost action: a faint neutral cell (matching inactive tabs) with an - -- accent label, so it sits consistently in a tab strip; the wash - -- brightens it on hover. - self:SetBackdropColor(1, 1, 1, 0.05) - self:SetBackdropBorderColor(0, 0, 0, 0) - return - end - if tinted then - -- Persistent semantic accent: faint accent fill + medium accent border - -- at rest (label/icon accent-coloured in ApplyThemeColor). Hover adds a - -- full-accent border + the wash brightens the fill. - self:SetBackdropColor(a.r * 0.15, a.g * 0.15, a.b * 0.15, 0.9) - self:SetBackdropBorderColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 0.8) - return - end - if self.dfActive then - -- Selected toggle/segmented button: a subtle accent fill + a clear - -- accent border (more than the muted hover border, but toned down from - -- full so it doesn't read as a heavy bright outline). - self:SetBackdropColor(a.r * 0.3, a.g * 0.3, a.b * 0.3, 1) - self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) - elseif primary then - -- Filled accent CTA: a medium accent fill with a slightly darker - -- accent border (the same border-darker-than-fill relationship as the - -- standard hover) so it reads like an emphasised standard button, not - -- a dark fill ringed by a harsh bright outline. - self:SetBackdropColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 1) - self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) - elseif self.isTab and self.isActive then - self:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 1) - self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - else - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - if restBorder then - self:SetBackdropBorderColor(restBorder.r or restBorder[1], - restBorder.g or restBorder[2], - restBorder.b or restBorder[3], - restBorder.a or restBorder[4] or 1) - else - self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - end - end - end - - -- The hover wash's colour, factored out so it can be re-resolved at HOVER time - -- rather than only at build time. The theme listener registered below lands on - -- the button's PARENT, and a button parented into a scroll child -- every row - -- in the Filter Designer, the spell list, the binding editor -- hangs it on a - -- frame no theme walk ever visits. Its wash then stays frozen at whatever the - -- accent was when the page was built, so a raid-mode hover drew an orange - -- border (computed live in OnEnter) over a party-blue fill. The border was - -- always right; the wash simply wasn't asking again. - local function applyWash(c) - if neutralHover then - hl:SetVertexColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.55) - else - hl:SetVertexColor(c.r, c.g, c.b, (isTabStyle or ghost) and 0.15 or 0.30) - end - end - - btn.ApplyThemeColor = function(c) - applyWash(c) - if isTabStyle then - restBackdrop(btn, c) -- keep the tab transparent (no fill/border) - -- refresh the stripe colour + the active label to the new accent - if btn.dfTabStripe then btn.dfTabStripe:SetColorTexture(c.r, c.g, c.b, 1) end - if btn.dfActive and btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end - elseif ghost or tinted then - restBackdrop(btn, c) -- faint cell / tinted fill; accent-coloured label - if btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end - if btn.Icon then btn.Icon:SetVertexColor(c.r, c.g, c.b) end -- icon matches the accent label - elseif primary or btn.dfActive then - restBackdrop(btn, c) -- refresh persistent accent - end - end - - -- Toggle/segmented selection: btn:SetActive(true) marks the button as the - -- current selection (prominent accent border via restBackdrop); false returns - -- it to its normal rest. The owning group is responsible for clearing the - -- previously-active button. Works on any StyleButton'd button. - btn.SetActive = function(self, active) - self.dfActive = active and true or false - restBackdrop(self, accent or GetThemeColor()) - if isTabStyle then - -- Underline tab: show the accent stripe + accent label when active, - -- dim label when inactive. - local a = accent or GetThemeColor() - if self.dfTabStripe then - self.dfTabStripe:SetColorTexture(a.r, a.g, a.b, 1) - self.dfTabStripe:SetShown(self.dfActive) - end - if self.Text then - if self.dfActive then - self.Text:SetTextColor(a.r, a.g, a.b) - else - self.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - end - if fadeActiveText then - -- Status toggles: the active fill+border carry the "on" emphasis, so - -- the label/icon recede slightly when active (settled) and stay bright - -- when inactive (a clearer call to action). Alpha keeps this - -- independent of whatever colour the owner sets on the text/icon. - local a = self.dfActive and 0.7 or 1 - if self.Text then self.Text:SetAlpha(a) end - if self.Icon then self.Icon:SetAlpha(a) end - end - end - - -- Disabled / "greyed out": a dim backdrop + faint border, label/icon dimmed - -- via alpha (keeps their own colour, just recedes), and the hover wash + - -- border suppressed. The button stays natively enabled so a HookScript - -- tooltip can still explain WHY it's disabled; the owner's OnClick must - -- early-out on self.dfDisabled. SetDisabled(false) restores the normal/ - -- active/primary rest. - btn.SetDisabled = function(self, disabled) - disabled = disabled and true or false - -- Idempotent: bail when the state isn't actually changing. Tab refresh - -- paths (the Aura Designer's UpdateLayoutTabState) call SetDisabled(false) - -- on the sub-tabs on EVERY rebuild. Re-running the enable-restore below on - -- an already-enabled tab left the AD Effects/Layout tabs diverging from a - -- never-disabled tab (Global) and broke their hover wash — the only code - -- that ran on them but not on Global was this call. Skipping the no-op keeps - -- an enabled tab identical to one SetDisabled never touched. - if (self.dfDisabled and true or false) == disabled then return end - self.dfDisabled = disabled - if self.dfDisabled then - self:SetBackdropColor(C_ELEMENT.r * 0.55, C_ELEMENT.g * 0.55, C_ELEMENT.b * 0.55, 0.6) - self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.25) - -- Kill the hover wash through its VERTEX alpha — the same channel - -- ApplyThemeColor uses for the wash's rest STRENGTH. The old object- - -- alpha toggle (hl:SetAlpha 0/1) mixed two alpha channels, so a - -- disable->enable cycle could restore the wash at FULL strength - -- instead of 0.30 (seen live on the Filter Designer add/rename/ - -- delete buttons when switching a preset -> a custom filter). - local wc = accent or GetThemeColor() - hl:SetVertexColor(wc.r, wc.g, wc.b, 0) - if self.Text then self.Text:SetAlpha(0.35) end - if self.Icon then self.Icon:SetAlpha(0.35) end - else - self.ApplyThemeColor(accent or GetThemeColor()) -- re-assert the wash's rest colour + alpha (0.30 / 0.15) - restBackdrop(self, accent or GetThemeColor()) - if self.Text then self.Text:SetAlpha(1) end - if self.Icon then self.Icon:SetAlpha(1) end - end - end - -- The grey loop (RefreshChildStates) greys gated children via widget:SetEnabled. - -- Native Button:SetEnabled blocks clicks but won't dim a custom-backdrop button - -- (its backdrop/Text are custom, not native button regions), so layer a SetAlpha - -- dim on top. We route through native + SetAlpha, NOT SetDisabled — SetDisabled - -- stays natively clickable (it relies on an OnClick dfDisabled early-out the - -- consumer may not have) and fights the hover wash on SetActive toggles. - local nativeSetEnabled = btn.SetEnabled - btn.SetEnabled = function(self, enabled) - nativeSetEnabled(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - end - - btn.ApplyThemeColor(accent or GetThemeColor()) - if not accent then - btn.UpdateTheme = function() btn.ApplyThemeColor(GetThemeColor()) end - local root = opts.themeRoot or btn:GetParent() - if root then - root.ThemeListeners = root.ThemeListeners or {} - table.insert(root.ThemeListeners, btn) - end - end - - -- WHAT HOVERED LOOKS LIKE — the single implementation, so the mouse and a - -- proxying owner cannot drift apart. OnEnter/OnLeave below are thin wrappers. - -- - -- ⚠ NO CURRENT CONSUMER. The Filter Designer's membership button was the one - -- caller and went with the filters merge (FilterRegistry/Options.lua says so). - -- Kept because the pattern recurs and the Lock/UnlockHighlight subtlety below - -- is not obvious enough to want rediscovered. - -- - -- Call btn:SetHovered(true/false) when something ELSE owns the hit area and - -- forwards the click: a list row whose OnClick fires this button's action. - -- The row lighting its button says "this is what clicking the row does", and - -- it separates the button from the row's highlight by HUE, which is far more - -- robust than the couple of hundredths of grey that sit between C_HOVER and - -- C_ELEMENT (the Filter Designer's spell rows are exactly that case). - -- - -- ⚠ Only wire this where the owner's click REALLY performs this button's - -- action. A control the owner does not fire must not light up with it — - -- priming a destructive button that the row will not actually trigger is - -- worse than the legibility problem it would be solving. - -- - -- The wash lives on the HIGHLIGHT layer, which the client shows only for the - -- frame under the mouse, so a proxied hover needs Lock/UnlockHighlight — it - -- cannot just Show() the texture. The real mouseover is unaffected either - -- way: locking an already-hovered button is a no-op, and unlocking one still - -- under the mouse leaves the client's own highlight up. - local function applyHoverState(self, hovered) - if not hovered then - if isTabStyle or ghost then return end - if self:IsEnabled() and not self.dfDisabled then - restBackdrop(self, accent or GetThemeColor()) - end - return - end - -- Re-resolve the wash against the CURRENT theme, exactly as the border does - -- below (see applyWash). Skipped when the caller pinned a fixed accent, and - -- while disabled -- SetDisabled parks the wash at alpha 0 and it must stay - -- parked, or a disabled button would light up under the mouse. - if not accent and not self.dfDisabled then applyWash(GetThemeColor()) end - -- tab/ghost/neutral: only the auto wash, no accent border - if isTabStyle or ghost or neutralHover then return end - if self:IsEnabled() and not self.dfDisabled then - local a = accent or GetThemeColor() - if tinted then - self:SetBackdropBorderColor(a.r, a.g, a.b, 1) -- full accent border on hover - elseif self.dfActive then - -- keep the active border on hover (the wash still brightens the - -- fill, giving the hover cue). - self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) - else - -- border darkens to a shade of the accent; the HIGHLIGHT wash - -- brightens the fill. The wash is translucent (0.3), so the - -- full-opacity border still reads DARKER than the fill. Same for - -- primary — it keeps its edge and the brightening fill is the cue. - self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) - end - end - end - - -- The proxied entry point. Lock/Unlock is HERE and not in applyHoverState so - -- a real mouseover never locks: a pooled button hidden mid-hover (a list - -- refreshing under a stationary mouse) would miss its OnLeave and come back - -- lit. An owner calling SetHovered accepts that responsibility instead and - -- must clear it when it rebinds the row. - function btn:SetHovered(hovered) - -- Lock/UnlockHighlight are Button-only, and StyleButton is applied to a - -- few plain Frames too; those still get the border half of the state. - if self.LockHighlight then - if hovered then self:LockHighlight() else self:UnlockHighlight() end - end - applyHoverState(self, hovered) - end - - btn:SetScript("OnEnter", function(self) applyHoverState(self, true) end) - btn:SetScript("OnLeave", function(self) applyHoverState(self, false) end) - return btn -end - -function GUI:CreateButton(parent, text, width, height, func, iconName) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - local opts = { width = width or 120, height = height or 22, text = text } - -- Optional leading icon by Media\Icons name (14px to suit the small buttons). - if iconName then - opts.icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, size = 14 } - end - GUI:StyleButton(btn, opts) - btn:SetScript("OnClick", function(self) - if func then func(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - return btn -end - --- Standard close/dismiss button: a small square danger-toned button showing a --- "×" glyph. Replaces the many hand-rolled red close buttons on dialogs/panels. --- opts = { size (20), onClick, tooltip, tone }. --- tone = nil → dim grey "×" at rest → white on hover (close/dismiss; default) --- tone = "danger" → RED "×" at rest → brighter red on hover (inline destructive --- removes: list-item / tag removes). Both keep the red hover wash. --- A horizontal row of buttons, chained left-to-right with one gap, sized as a --- single layout slot. Pages were building this by hand every time -- a bare --- CreateFrame, then SetPoint("LEFT", prev, "RIGHT", 6, 0) per button, plus the --- HookScript/ShowTooltip pair on any button that needed a tooltip. --- --- buttons = { { label, width, onClick, icon, tooltip, key }, ... } --- tooltip is a ShowTooltip spec ({title, lines, tone}); a button IS its own --- label, so it takes the whole-widget hover rather than AttachTooltip's --- label-only hit area (AttachTooltip returns early without a label region). --- key names the button on row.buttons for a caller that needs it later. -function GUI:CreateButtonRow(parent, buttons, opts) - opts = opts or {} - local gap, h = opts.gap or 6, opts.height or 24 - local row = CreateFrame("Frame", nil, parent) - row:SetSize(opts.width or 540, opts.rowHeight or (h + 4)) - row.buttons = {} - local prev - for i, spec in ipairs(buttons) do - local btn = GUI:CreateButton(row, spec.label, spec.width or 80, h, spec.onClick, spec.icon) - if prev then - btn:SetPoint("LEFT", prev, "RIGHT", gap, 0) - else - btn:SetPoint("LEFT", 0, 0) - end - if spec.tooltip then - btn:HookScript("OnEnter", function(self) GUI:ShowTooltip(self, spec.tooltip) end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - row.buttons[spec.key or i] = btn - prev = btn - end - return row -end - -function GUI:CreateCloseButton(parent, opts) - opts = opts or {} - local size = opts.size or 20 - -- Rest/hover glyph colours: grey→white for dismiss, red→brighter-red for inline - -- destructive removes. The StyleButton red wash + border is shared by both. - local restColor = (opts.tone == "danger") and { r = 0.9, g = 0.45, b = 0.45 } or C_TEXT_DIM - local hoverColor = (opts.tone == "danger") and { r = 1, g = 0.4, b = 0.4 } or { r = 1, g = 1, b = 1 } - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = size, height = size, - tone = "danger", - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", - size = math.max(8, math.floor(size * 0.55)), - color = restColor, - }, - }) - btn:HookScript("OnEnter", function(self) self.Icon:SetVertexColor(hoverColor.r, hoverColor.g, hoverColor.b) end) - btn:HookScript("OnLeave", function(self) self.Icon:SetVertexColor(restColor.r, restColor.g, restColor.b) end) - btn:SetScript("OnClick", function(self) - if opts.onClick then opts.onClick(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - if opts.tooltip then - btn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = opts.tooltip}) - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - return btn -end - --- A bare clickable GLYPH: an icon with a hover cue and NO chrome. This is the --- small affordance that lives inside a row or a card header -- reorder arrows, an --- eye visibility toggle, a clear-search "x" -- where a button box would be --- heavier than the thing it acts on. --- --- Deliberately its own helper: StyleButton always draws chrome, and --- CreateCloseButton is specifically the chromed "x". Before this existed, ~16 --- sites hand-rolled the same three lines (create texture, tint it dim, brighten --- it in OnEnter and restore in OnLeave). --- --- NOT this: a labelled row that merely CONTAINS an icon (a collapsible section --- header with a title + chevron, a collapse bar). There the click target is the --- whole row, not the glyph. --- --- opts: --- texture icon path tooltip / onClick --- size both dims (16) width / height -- button box, when the hit --- area is deliberately bigger than the art --- iconSize art size (= size) rotation -- radians, so one arrow texture --- can serve both directions --- color rest tint (C_TEXT_DIM) hoverColor (white) --- --- Returns the button with .Icon plus: --- :SetGlyph(texture, color) re-point the art for a state change. The colour --- passed becomes the new REST colour, so a later OnLeave restores the --- state rather than snapping back to the original default. --- :SetGlyphHover(bool) suppress the hover brighten -- an "off" state should --- not light up under the mouse. -function GUI:CreateGlyphButton(parent, opts) - opts = opts or {} - local size = opts.size or 16 - local iconSize = opts.iconSize or size - - local btn = CreateFrame("Button", nil, parent) - btn:SetSize(opts.width or size, opts.height or size) - - local icon = btn:CreateTexture(nil, "OVERLAY") - icon:SetSize(iconSize, iconSize) - icon:SetPoint("CENTER", 0, 0) - if opts.texture then icon:SetTexture(opts.texture) end - if opts.rotation then icon:SetRotation(opts.rotation) end - btn.Icon = icon - - local function unpackColor(c, dr, dg, db) - if not c then return dr, dg, db end - return c.r or c[1], c.g or c[2], c.b or c[3] - end - local hr, hg, hb = unpackColor(opts.hoverColor, 1, 1, 1) - btn._glyphHover = true - btn._glyphRest = { unpackColor(opts.color, C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) } - icon:SetVertexColor(unpack(btn._glyphRest)) - - function btn:SetGlyph(texture, color) - if texture then self.Icon:SetTexture(texture) end - if color then self._glyphRest = { unpackColor(color) } end - self.Icon:SetVertexColor(unpack(self._glyphRest)) - end - - function btn:SetGlyphHover(enabled) - self._glyphHover = enabled and true or false - end - - btn:SetScript("OnEnter", function(self) - if self._glyphHover then self.Icon:SetVertexColor(hr, hg, hb) end - if opts.tooltip then GUI:ShowTooltip(self, { title = opts.tooltip }) end - end) - btn:SetScript("OnLeave", function(self) - self.Icon:SetVertexColor(unpack(self._glyphRest)) - if opts.tooltip then GUI:HideTooltip() end - end) - if opts.onClick then - btn:SetScript("OnClick", function(self) opts.onClick(self) end) - end - return btn -end - --- Shared panel/dialog root backdrop: a solid dark panel with an optional 1px --- border. Centralises the inline SetBackdrop blocks scattered across dialogs and --- floating panels. opts = { bgAlpha (0.95), border (true), borderColor {r,g,b,a} --- or {r,g,b,a array} }. -function GUI:CreatePanelBackdrop(frame, opts) - opts = opts or {} - local bg = opts.bgColor or C_PANEL - -- A panel's border is a full-strength 1px line, where the element default is - -- half-alpha, so the border colour is always passed explicitly rather than - -- left to CreateElementBackdrop's default. - local bc = opts.borderColor - return CreateElementBackdrop(frame, { - outline = opts.border ~= false, - bgColor = { bg.r or bg[1], bg.g or bg[2], bg.b or bg[3], - opts.bgAlpha or bg.a or 0.95 }, - borderColor = bc and { bc.r or bc[1], bc.g or bc[2], bc.b or bc[3], - bc.a or bc[4] or 1 } - or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 1 }, - }) -end - --- Mover chrome: the translucent tinted plate a drag surface wears while the frames --- are unlocked. This is a SEPARATE helper from CreateElementBackdrop, not a flag on --- it, because a mover has the opposite job from settings chrome -- it is meant to --- shout. Giving movers the neutral element look would be a bug, not consistency. --- --- The hue comes from the GUI theme constants (C_ACCENT party purple-blue / C_RAID --- raid orange) instead of a hardcoded literal, so retheming moves the movers too. --- --- ⚠ Which POLE is the caller's choice, not GUI.SelectedMode's, because a mover --- belongs to the thing it moves: the raid mover must stay orange even while the --- options window happens to be showing a party page. Pass isRaid where the site --- knows; omit it only where the mover genuinely has no mode, and it will follow --- the selected mode. --- --- opts: --- isRaid true/false pins the pole; omit to follow GUI.SelectedMode --- color {r,g,b} or {[1],[2],[3]} -- explicit override, for a mover whose --- colour is a user setting rather than the theme --- fillAlpha default 0.30 borderAlpha default 0.80 --- fill = false outline only edgeSize default 2 --- --- The returned frame gains :RefreshMoverTint(), which re-resolves the hue against --- the theme as it stands now -- call it if the mode changes while a mover is shown. -function GUI:CreateMoverBackdrop(frame, opts) - opts = opts or {} - local c = opts.color - if not c then - if opts.isRaid ~= nil then c = GetThemeColorFor(opts.isRaid) - else c = GetThemeColor() end - end - local r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] - CreateElementBackdrop(frame, { - fill = opts.fill, - edgeSize = opts.edgeSize or 2, - bgColor = { r, g, b, opts.fillAlpha or 0.30 }, - borderColor = { r, g, b, opts.borderAlpha or 0.80 }, - }) - frame.RefreshMoverTint = function(self, newOpts) - return GUI:CreateMoverBackdrop(self, newOpts or opts) - end - return frame -end - --- The element backdrop, exposed to consumer files (the stylers in this file use --- the local directly). Nothing outside should be calling SetBackdrop itself -- --- route it through here so the look, and the border mechanism, stay in one --- place. See the local for the opts contract: fill, outline, bgColor, --- borderColor, backdropEdge. -function GUI:CreateElementBackdrop(frame, opts) - return CreateElementBackdrop(frame, opts) -end - --- ============================================================ --- DESIGNER TEMPLATE BAR (shared by the Aura / Text Designer editors) --- Compact row: "Template: [dropdown ▾] [New][Duplicate][Rename][Delete]". --- NOTE: the saved keys are still auraDesignerPreset(s) / textDesignerPreset(s) -- --- only the LABELS became "template". The keys are persisted and exported, so --- renaming them would cost a profile migration for nothing a user can see. --- "Preset" now means only the built-in filter sets (FilterRegistry) and the --- export/test quick-picks. --- Picking a template assigns it to the mode (opts.getMode()) AND retargets the --- editor; the buttons manage the library. After any change the bar calls --- opts.onChange() so the host page can rebuild + refresh live frames. --- opts = { kind = "aura"|"text", getMode = fn->mode, onChange = fn }. --- Returns the bar frame; call bar:Refresh() to resync. --- ============================================================ - --- The addon's ONE name prompt. This was hand-rolled twice against Blizzard's --- StaticPopup edit box — here and in the Filter Designer — each copy carrying --- the same `self.EditBox or self.editBox or self:GetEditBox()` fallback for a --- field name that moves between client versions. Both now come through here and --- get DF chrome, so there is nothing left to keep in step. --- opts = { title, message, default, acceptLabel, maxLetters, onAccept(text) } -function GUI:PromptName(opts) - opts = opts or {} - DF:ShowPopupInput({ - title = opts.title, - message = opts.message, - text = opts.default or "", - acceptLabel = opts.acceptLabel, - maxLetters = opts.maxLetters or 40, - onAccept = function(text) - -- Trim here so every caller's uniqueness check and empty-name - -- fallback sees the same thing. - if opts.onAccept then opts.onAccept(strtrim(text or "")) end - end, - }) -end - -local function PromptPresetName(message, default, acceptLabel, callback) - GUI:PromptName({ - title = L["Template Name"], - message = message, - default = default, - acceptLabel = acceptLabel, - onAccept = callback, - }) -end - -local function ConfirmDeletePreset(kind, name, onDone) - DF:ShowPopupAlert({ - title = L["Delete Template"], - message = format(L["Delete template \"%s\"? Anything using it reverts to Default."], name), - buttons = { - { - label = L["Delete"], - onClick = function() - if DF.DeleteDesignerPreset then - DF:DeleteDesignerPreset(kind, name) - if onDone then onDone() end - end - end, - }, - { label = L["Cancel"] }, - }, - }) -end - -function GUI:CreateDesignerPresetBar(parent, opts) - opts = opts or {} - local kind = opts.kind or "aura" - local getMode = opts.getMode or function() return "party" end - local onChange = opts.onChange or function() end - - local bar = CreateFrame("Frame", nil, parent) - bar:SetHeight(24) - - local label = bar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("LEFT", 0, 0) - label:SetText(L["Template:"]) - label:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local function CurrentName() - return DF:GetModeDesignerPresetName(kind, getMode()) - end - - -- True while editing a raid auto-layout (the only context with an "inherit - -- the global preset" choice — normal party/raid modes ARE the base). - -- Mode-gated: auto-layouts are RAID-only, but the GUI can be reopened on - -- the party tab while editing (ToggleGUI re-derives SelectedMode) — the - -- PARTY preset bar must not show layout state, and its "Inherit (Global)" - -- click must never clear the RAID layout's override. - local function IsEditingLayout() - return getMode() == "raid" - and DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() - end - - -- The label to show on the dropdown button: "Inherit (Global)" when the - -- edited layout has no override, otherwise the resolved preset name. - local function CurrentLabel() - if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then - return L["Inherit (Global)"] - end - return CurrentName() - end - - -- Dropdown button + menu (rebuilt on each open so it always reflects the lib) - local ddBtn = CreateFrame("Button", nil, bar, "BackdropTemplate") - ddBtn:SetSize(150, 22) - ddBtn:SetPoint("LEFT", label, "RIGHT", 6, 0) - CreateElementBackdrop(ddBtn) - ddBtn.text = ddBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - ddBtn.text:SetPoint("LEFT", 6, 0) - ddBtn.text:SetPoint("RIGHT", -16, 0) - ddBtn.text:SetJustifyH("LEFT") - ddBtn.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - local arrow = ddBtn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -4, 0) - arrow:SetSize(10, 10) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local menu = CreateFrame("Frame", nil, ddBtn, "BackdropTemplate") - menu:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menu) - menu:SetPoint("TOPLEFT", ddBtn, "BOTTOMLEFT", 0, -1) - menu:SetWidth(150) - CreatePanelBackdrop(menu) - menu:Hide() - - -- Row pool: frames can't be garbage-collected in WoW, so recreating the - -- items on every open (the old Hide+SetParent(nil) approach) leaked a row - -- set per click. Reuse instead. - local menuRows = {} - local function BuildMenu() - for _, row in ipairs(menuRows) do row:Hide() end - local used = 0 - local y = -4 - local function AddItem(label, onClick) - used = used + 1 - local item = menuRows[used] - if not item then - item = CreateFrame("Button", nil, menu) - item:SetHeight(20) - item.text = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - item.text:SetPoint("LEFT", 4, 0) - item:SetScript("OnEnter", function(s) s.text:SetTextColor(1, 1, 1) end) - item:SetScript("OnLeave", function(s) s.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end) - item:SetScript("OnClick", function(s) - s.onClick() - menu:Hide() - bar:Refresh() - onChange() - end) - menuRows[used] = item - end - item:ClearAllPoints() - item:SetPoint("TOPLEFT", 4, y) - item:SetPoint("TOPRIGHT", -4, y) - item.text:SetText(label) - item.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - item.onClick = onClick - item:Show() - y = y - 20 - end - -- "Inherit (Global)" — only while editing a raid auto-layout. Clears the - -- layout's preset override so it follows your global preset. - if IsEditingLayout() then - AddItem(L["Inherit (Global)"], function() - if DF.InheritLayoutDesignerPreset then DF:InheritLayoutDesignerPreset(kind) end - end) - end - for _, name in ipairs(DF:ListDesignerPresets(kind)) do - AddItem(name, function() DF:SetModeDesignerPreset(kind, getMode(), name) end) - end - menu:SetHeight(-y + 4) - end - ddBtn:SetScript("OnClick", function() - if menu:IsShown() then menu:Hide() else BuildMenu(); menu:Show() end - end) - - -- SHARING MARKER. A template can be pointed at by the other mode, a pinned - -- set or an auto layout, and editing it then changes every one of them — - -- which nothing on this bar used to say. The dropdown is a fixed 150px, so - -- the FACT rides as a glyph and the NAMES go in the tooltip, which is free. - -- - -- Deliberately NOT clickable. Splitting a shared template off for this mode - -- is exactly what Duplicate does, two buttons to the right, and Duplicate - -- also lets you name the copy. - local shareIcon = ddBtn:CreateTexture(nil, "OVERLAY") - shareIcon:SetSize(12, 12) - shareIcon:SetPoint("RIGHT", arrow, "LEFT", -3, 0) - shareIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync") - shareIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - shareIcon:Hide() - - -- Read off the REFS, not the party/raid sync flag: sharing by hand (picking - -- the other mode's preset from this dropdown) counts exactly the same, and a - -- ref can't fall out of step with itself. - local function SharedWith() - if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then - return {} -- inheriting: this bar isn't sitting on a preset of its own - end - return (DF.ListDesignerPresetUsers and DF:ListDesignerPresetUsers(kind, CurrentName(), getMode())) or {} - end - - -- The consumers (Party/Raid, Auto Layouts, Pinned Frames) are spread across - -- three other pages, so naming them is the one thing this tooltip has to do - -- — the bar itself already shows what a template is. "can" holds for all - -- four: Auto Layouts and Pinned Frames may inherit their mode's instead - -- (a nil ref), and Party/Raid resolve to a default when nothing is set. - -- Names match their page titles so they are findable. - ddBtn:SetScript("OnEnter", function(self) - local lines = { - L["Templates can be used by Party, Raid, Auto Layouts and Pinned Frames."], - } - local shared = SharedWith() - if #shared > 0 then - lines[#lines + 1] = " " - lines[#lines + 1] = { text = format(L["Also used by: %s"], table.concat(shared, ", ")), accent = true } - -- "there" points back at the list above, so the consequence needs no - -- nouns of its own. It has to be said: a shared template's edits - -- reach a screen you are not looking at, and naming the users is - -- only the fact, not the warning. - lines[#lines + 1] = L["Edits apply there too."] - end - GUI:ShowTooltip(self, { title = L["Templates"], lines = lines }) - end) - ddBtn:SetScript("OnLeave", function() GUI:HideTooltip() end) - - -- When editing a raid auto-layout, default the NEW preset name to the - -- layout's name (e.g. editing "31-40" → prefill "31-40") so making a - -- per-layout preset is one click + Enter. nil (blank) otherwise. (Duplicate - -- names after its source preset, not the layout.) - local function EditingLayoutName() - if not IsEditingLayout() then return nil end -- mode-gated (raid only) - local apu = DF.AutoProfilesUI - if apu and apu.editingProfile then - return apu.editingProfile.name - end - return nil - end - - -- Action buttons. opts.iconButtons = true swaps the labeled buttons for - -- compact tooltipped icon-only buttons (22x22) — used where the bar shares - -- a row with other controls (Aura Designer header). Default stays labeled - -- (Text Designer) so existing callers are untouched. - local function CreateAction(labelText, iconName, width, onClick) - if opts.iconButtons then - local b = CreateFrame("Button", nil, bar, "BackdropTemplate") - GUI:StyleButton(b, { - width = 22, height = 22, - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, - size = 14, color = C_TEXT, - }, - }) - b:SetScript("OnClick", function(self) - onClick(self) - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - b:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = labelText}) - end) - b:HookScript("OnLeave", function() GUI:HideTooltip() end) - return b - end - return GUI:CreateButton(bar, labelText, width, 22, onClick) - end - - local newBtn = CreateAction(L["New"], "add", 48, function() - PromptPresetName(L["Name the new template:"], EditingLayoutName() or "", L["Create"], function(text) - local n = DF:CreateDesignerPreset(kind, text) - if n then - DF:SetModeDesignerPreset(kind, getMode(), n) - bar:Refresh(); onChange() - end - end) - end) - newBtn:SetPoint("LEFT", ddBtn, "RIGHT", 6, 0) - - local dupBtn = CreateAction(L["Duplicate"], "content_copy", 72, function() - local cur = CurrentName() - -- Duplicate defaults to " copy" (New uses the layout name, but a - -- duplicate is of a specific preset, so name it after the source). - PromptPresetName(L["Name the duplicated template:"], cur .. " copy", L["Duplicate"], function(text) - local n = DF:DuplicateDesignerPreset(kind, cur, text) - if n then - DF:SetModeDesignerPreset(kind, getMode(), n) - bar:Refresh(); onChange() - end - end) - end) - dupBtn:SetPoint("LEFT", newBtn, "RIGHT", 4, 0) - - local renameBtn = CreateAction(L["Rename"], "edit", 62, function() - local cur = CurrentName() - if cur == DF.DEFAULT_PRESET then return end - PromptPresetName(L["Rename template:"], cur, L["Rename"], function(text) - DF:RenameDesignerPreset(kind, cur, text) - bar:Refresh(); onChange() - end) - end) - renameBtn:SetPoint("LEFT", dupBtn, "RIGHT", 4, 0) - - local delBtn = CreateAction(L["Delete"], "delete", 56, function() - local cur = CurrentName() - if cur == DF.DEFAULT_PRESET then return end - ConfirmDeletePreset(kind, cur, function() bar:Refresh(); onChange() end) - end) - delBtn:SetPoint("LEFT", renameBtn, "RIGHT", 4, 0) - - local function SetActionEnabled(btn, on) - if on then - btn:Enable() - if btn.Text then btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end - if btn.Icon then btn.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end - else - btn:Disable() - -- greyed: Default can't be renamed/deleted - if btn.Text then btn.Text:SetTextColor(0.4, 0.4, 0.4) end - if btn.Icon then btn.Icon:SetVertexColor(0.4, 0.4, 0.4) end - end - end - - function bar:Refresh() - ddBtn.text:SetText(CurrentLabel()) - -- Make room for the share glyph only while it's up, so an unshared - -- preset keeps the full label width. - local isShared = #SharedWith() > 0 - shareIcon:SetShown(isShared) - ddBtn.text:ClearAllPoints() - ddBtn.text:SetPoint("LEFT", 6, 0) - ddBtn.text:SetPoint("RIGHT", isShared and -30 or -16, 0) - -- Rename/Delete act on the resolved preset; disable for the non-editable - -- Default and while a layout is inheriting (you're following the global, - -- not sitting on a layout-specific preset). - local inheriting = IsEditingLayout() and DF.IsLayoutDesignerInheriting - and DF:IsLayoutDesignerInheriting(kind) - local canModify = (CurrentName() ~= DF.DEFAULT_PRESET) and not inheriting - SetActionEnabled(renameBtn, canModify) - SetActionEnabled(delBtn, canModify) - end - - bar:Refresh() - -- The sharing glyph reflects OTHER refs (the other mode, a pinned set, an - -- auto layout), which can change without changing THIS mode's — and both - -- designer pages early-return their rebuild when their own preset is - -- unchanged, so the glyph would sit stale. Register the live bar so the - -- shared page refresh can reach it. One slot per kind: a rebuilt page - -- overwrites its own entry, and a torn-down bar is hidden, so nothing - -- accumulates. - GUI._designerPresetBars = GUI._designerPresetBars or {} - GUI._designerPresetBars[kind] = bar - return bar -end - -function GUI:RefreshDesignerPresetBars() - for _, bar in pairs(GUI._designerPresetBars or {}) do - if bar.Refresh and bar:IsShown() then bar:Refresh() end - end -end - --- Creates a button with an icon and text --- iconName is the name of the icon file (without path/extension) --- iconSize is optional (defaults to 16) --- align: "center" (default) or "left". Pass "left" for wide / full-width --- list-style buttons where centred content floats (see GUI:StyleButton). -function GUI:CreateIconButton(parent, iconName, text, width, height, func, iconSize, align) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = width or 120, height = height or 22, - text = text, - align = align, - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, - size = iconSize or 18, - color = C_TEXT, - }, - }) - - btn:SetScript("OnClick", function(self) - if func then func(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - return btn -end - --- Creates a \"See Also:\" section with clickable links to related pages --- links = { {pageId = \"display_tooltips\", label = \"Tooltips\"}, ... } -function GUI:CreateSeeAlso(parent, links) - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - container:SetHeight(32) - -- The page's own footer: pinned to the bottom of the viewport on a SHORT - -- page instead of floating wherever the content happened to end. See the - -- footer block in the page layout. - container.isPageFooter = true - CreateElementBackdrop(container, { - bgColor = { 0.1, 0.1, 0.1, 0.5 }, - borderColor = { 0.3, 0.3, 0.3, 0.8 }, - }) - - local label = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("TOPLEFT", 8, -10) - label:SetText(L["See Also:"]) - label:SetTextColor(0.7, 0.7, 0.7) - - local linkButtons = {} - local separators = {} - - for i, linkData in ipairs(links) do - local link = CreateFrame("Button", nil, container) - link:SetHeight(16) - - local linkText = link:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - linkText:SetPoint("TOPLEFT", 0, -1) - linkText:SetText(linkData.label) - local c = GetThemeColor() - linkText:SetTextColor(c.r, c.g, c.b) - link.text = linkText - link.textWidth = linkText:GetStringWidth() + 4 - link:SetWidth(link.textWidth) - - link:SetScript("OnEnter", function(self) - local h = GUI:LinkHoverColor(c) - linkText:SetTextColor(h.r, h.g, h.b) - end) - link:SetScript("OnLeave", function(self) - linkText:SetTextColor(c.r, c.g, c.b) - end) - link:SetScript("OnClick", function() - if GUI.SelectTab then - GUI.SelectTab(linkData.pageId) - end - end) - - table.insert(linkButtons, link) - - -- Create separator (hidden by default, shown as needed) - if i < #links then - local sep = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - sep:SetText("•") - sep:SetTextColor(0.5, 0.5, 0.5) - table.insert(separators, sep) - end - end - - -- Layout function that handles wrapping - local function LayoutLinks() - local containerWidth = container:GetWidth() - if containerWidth < 50 then return end -- Not sized yet - - local labelWidth = label:GetStringWidth() + 16 - local firstLinkX = labelWidth -- Where first link starts - local xOffset = labelWidth - local yOffset = -9 - local lineHeight = 18 - local maxX = containerWidth - 10 - local rowCount = 1 - - -- First pass: determine which links are on which row - local linkRows = {} - local tempX = labelWidth - local currentRow = 1 - - for i, link in ipairs(linkButtons) do - local linkWidth = link.textWidth - local sepWidth = (i < #linkButtons) and 14 or 0 - - -- Check if we need to wrap - if tempX + linkWidth > maxX and tempX > labelWidth then - currentRow = currentRow + 1 - tempX = firstLinkX - end - - linkRows[i] = currentRow - tempX = tempX + linkWidth + sepWidth - end - - rowCount = currentRow - - -- Second pass: position elements - xOffset = labelWidth - local lastRowForLink = 1 - - for i, link in ipairs(linkButtons) do - local linkWidth = link.textWidth - - -- Check if we need to wrap to new line - if linkRows[i] > lastRowForLink then - xOffset = firstLinkX - yOffset = yOffset - lineHeight - lastRowForLink = linkRows[i] - end - - link:ClearAllPoints() - link:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset) - - xOffset = xOffset + linkWidth + 2 - - -- Position separator only if next link is on same row - if separators[i] then - if linkRows[i + 1] == linkRows[i] then - separators[i]:ClearAllPoints() - separators[i]:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset - 1) - separators[i]:Show() - xOffset = xOffset + 12 - else - separators[i]:Hide() - end - end - end - - -- Adjust container height based on rows. - -- - -- Snapped HERE, where the number is computed, because this widget - -- MEASURES ITSELF: LayoutLinks runs from OnSizeChanged and from a - -- C_Timer.After(0), i.e. a frame AFTER the page layout has run. - -- Correcting the height after the fact cannot win -- whatever sets a - -- grid-aligned height, this function overwrites it on the next frame, - -- and the bar's bottom edge ends up split across two device rows. - -- Measured: 28 units at 1.40625 px/unit is 39.375px, 0.375 off. - local newHeight = SnapLen(container, 10 + (rowCount * lineHeight)) - container:SetHeight(newHeight) - container.layoutHeight = newHeight + 5 - end - - container:SetScript("OnSizeChanged", LayoutLinks) - - -- Initial layout after a frame (to let width be set) - C_Timer.After(0, LayoutLinks) - - return container -end - --- ========================================================================= --- OVERRIDE INDICATORS FOR AUTO PROFILES --- ========================================================================= --- Helper function to add override indicators (star, reset button, global value text) --- to widget containers when editing an auto profile - --- Debug flag - when true, shows all reset buttons regardless of override state -S.overrideDebugMode = false - --- Track all widgets with override indicators for refresh -local overrideWidgets = {} - --- Function to check if debug mode is active (exposed for other files) -local function IsOverrideDebugMode() - return S.overrideDebugMode -end -GUI.IsOverrideDebugMode = IsOverrideDebugMode - --- Function to refresh all override indicators -local function RefreshAllOverrideIndicators() - for _, widget in ipairs(overrideWidgets) do - if widget and widget.UpdateOverrideIndicators then - widget:UpdateOverrideIndicators() - end - end - -- Also refresh position override indicator - if GUI.UpdatePositionOverrideIndicator then - GUI.UpdatePositionOverrideIndicator() - end - -- Refresh tab override stars (auto-profiles) - if DF.AutoProfilesUI and DF.AutoProfilesUI.RefreshTabOverrideStars then - DF.AutoProfilesUI:RefreshTabOverrideStars() - end -end -GUI.RefreshAllOverrideIndicators = RefreshAllOverrideIndicators - --- Allow other files to register widgets with override indicators -function GUI.RegisterOverrideWidget(widget) - table.insert(overrideWidgets, widget) -end - --- NOT a dump, despite what the old description ("Auto layout override table --- dump") claimed — it prints no table. It forces every reset button / override --- marker visible regardless of override state, so you can see which controls --- carry the machinery at all. S.overrideDebugMode is live: read by --- GUI.IsOverrideDebugMode and three marker call sites. -DF:RegisterDebugSlash("DFOVERRIDEDEBUG", "Force-show every override marker and reset button", true, "/dfoverridedebug") -SlashCmdList["DFOVERRIDEDEBUG"] = function() - S.overrideDebugMode = not S.overrideDebugMode - DF:Say("Override debug mode " .. (S.overrideDebugMode and "ENABLED" or "DISABLED")) - -- Refresh all override indicators - RefreshAllOverrideIndicators() - -- Also update position panel if open - if DF.positionPanel and DF.positionPanel.UpdatePositionOverride then - DF.positionPanel.UpdatePositionOverride() - end -end - --- ============================================================ --- SHARED OVERRIDE CONTROLS --- One "override active" marker (a coloured dot) + one "reset to global" button --- (red, icon-only, danger tone — reads like the Reset Page button), so every --- override control across the addon speaks one visual language. Callers create --- them, position the returned frames, and toggle Show/Hide. --- ============================================================ --- Single source of truth for the override-marker colour. -GUI.OVERRIDE_MARKER_COLOR = { 1, 0.8, 0.2 } - --- A coloured dot marking "this setting is overridden". Returns a hidden Button --- so the caller can set .tooltipText / .tooltipSubText for a hover tooltip. --- `size` = dot diameter in px (default 12); the hit frame is a touch larger. -function GUI:CreateOverrideMarker(parent, size) - size = size or 12 - local c = GUI.OVERRIDE_MARKER_COLOR - local btn = CreateFrame("Button", nil, parent) - btn:SetSize(size + 6, size + 6) - -- Only ever a hover-tooltip target; let clicks fall through so a marker - -- placed on a clickable parent (e.g. a nav tab) doesn't eat its clicks. - -- SetPropagateMouseClicks is PROTECTED on 12.1 (ADDON_ACTION_BLOCKED if called - -- under combat lockdown — e.g. opening/refreshing settings in combat); skip it - -- there. Only matters when the marker sits on a clickable parent, and combat - -- GUI edits are the rare case. - if btn.SetPropagateMouseClicks and not InCombatLockdown() then btn:SetPropagateMouseClicks(true) end - local icon = btn:CreateTexture(nil, "OVERLAY") - icon:SetPoint("CENTER") - icon:SetSize(size, size) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\dot") - icon:SetVertexColor(c[1], c[2], c[3]) - btn.icon = icon - btn:SetScript("OnEnter", function(s) - if s.tooltipText then - GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipSubText and { s.tooltipSubText } or nil }) - end - end) - btn:SetScript("OnLeave", function() GUI:HideTooltip() end) - btn:Hide() - return btn -end - --- A bare checkbox sized for a LIST ROW — no label, no db binding, no settings-row --- geometry. GUI:CreateCheckbox is a whole 30px settings row with its own label and --- hit rect, which is the wrong shape entirely inside a 22px pooled list row that --- already owns its own text, count and selection accent. --- --- The caller drives it: :SetChecked(bool) to paint, opts.onClick to react. It does --- NOT read or write the db itself, because a list row's meaning changes per bind --- (a pooled row is a different filter every refresh) and a captured dbKey would go --- stale the moment the pool rebinds. --- --- ⚠ Clicks deliberately do NOT propagate. The override marker above lets them fall --- through because it is a passive marker; this is a control, and on a clickable row --- the two gestures must stay separate — tick the box to switch the filter on, click --- anywhere else to select it. Nothing here calls SetPropagateMouseClicks, which is --- PROTECTED on 12.1 anyway (see the note in CreateOverrideMarker). --- --- ⚠ The BOX ITSELF is GUI:StyleCheckButton, the addon's one checkbox look — do not --- hand-roll it again. This was hand-rolled once and drifted four ways: it drew the --- Media\Icons\check GLYPH where every other checkbox in the addon draws a filled --- WHITE8x8 square (a different SYMBOL, not a different style), it skipped PixelUtil --- so it alone was unsnapped, it had no hover wash, and it recoloured its BORDER when --- checked, which nothing else does. CreateDebugCategoryRow is the precedent for this --- exact case — a list row with a checkbox — at the same size. --- --- manualCheck because this is a plain Button, not a CheckButton: SetChecked below --- drives the mark. A real CheckButton would draw its checked mark through the native --- checked state, which has no disabled-checked texture here — so a greyed-but-ticked --- row (every filter row while All Buffs is on) would lose its tick entirely. --- --- opts: { size = 16, checkSize = 9, onClick = function(checked) end, --- tooltip = title, tooltipDesc = line } -function GUI:CreateRowToggle(parent, opts) - opts = opts or {} - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleCheckButton(btn, { - size = opts.size or 16, - checkSize = opts.checkSize or 9, - manualCheck = true, - }) - - btn.checked = false - function btn:SetChecked(on) - self.checked = on and true or false - -- Re-tint on every paint. StyleCheckButton registers its theme listener on - -- this button's PARENT, which for a pooled list row is a frame inside a - -- scroll child that the page's theme walk never visits (same trap as - -- StyleButton's wash). The list rebinds every row on refresh, and a refresh - -- is what a mode switch produces, so painting the accent here is what - -- actually carries party purple -> raid orange. - self.ApplyThemeColor(GetThemeColor()) - self.Check:SetShown(self.checked) - end - - btn:SetScript("OnClick", function(s) - if s.onClick then s.onClick(not s.checked) end - end) - btn:SetScript("OnEnter", function(s) - if s.tooltipText then - GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipDesc and { s.tooltipDesc } or nil }) - end - end) - btn:SetScript("OnLeave", function() GUI:HideTooltip() end) - - btn.onClick = opts.onClick - btn.tooltipText = opts.tooltip - btn.tooltipDesc = opts.tooltipDesc - btn:SetChecked(false) - return btn -end - --- A "reset to global" button — red, icon-only, danger tone (matches the Reset --- Page button). Returns a hidden Button. opts: { size = 18, tooltip = title, --- tooltipDesc = line, onClick = fn }. -function GUI:CreateOverrideResetButton(parent, opts) - opts = opts or {} - local size = opts.size or 18 - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = size, height = size, - tone = "danger", - icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = size - 6 }, - }) - btn:Hide() - -- StyleButton owns OnEnter (hover wash); hook the tooltip on top. - btn:HookScript("OnEnter", function(self) - if opts.tooltip then - GUI:ShowTooltip(self, { title = opts.tooltip, lines = opts.tooltipDesc and { opts.tooltipDesc } or nil }) - end - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - if opts.onClick then btn:SetScript("OnClick", opts.onClick) end - return btn -end - -local function AddOverrideIndicators(container, lbl, dbKey, onReset, verticalOffset, optionsMap, dbTable) - -- Skip for proxy tables (e.g. Aura Designer) that don't support per-key override tracking - if dbTable and rawget(dbTable, "_skipOverrideIndicators") then return end - verticalOffset = verticalOffset or 0 - container.overrideOptionsMap = optionsMap - - -- Reset button (red, icon-only) at top-right; the override marker (dot) - -- sits to its left. Both are shared helpers (GUI:CreateOverride*). - local resetBtn = GUI:CreateOverrideResetButton(container, { - tooltip = L["Reset to Global"], - tooltipDesc = L["Reset this setting to its global value."], - onClick = function() if onReset then onReset() end end, - }) - resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, verticalOffset) - container.overrideResetBtn = resetBtn - - local starBtn = GUI:CreateOverrideMarker(container) - starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) - container.overrideStar = starBtn - - -- Global value text (shown when in edit mode) - positioned inline after label - local globalText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - globalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - globalText:SetTextColor(0.4, 0.4, 0.4) - globalText:Hide() - container.overrideGlobalText = globalText - - -- Checkmark icon for matching global value - local checkIcon = container:CreateTexture(nil, "OVERLAY") - checkIcon:SetSize(8, 8) - checkIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\check") - checkIcon:SetVertexColor(0.3, 0.7, 0.3) - checkIcon:Hide() - container.overrideCheckIcon = checkIcon - - -- Store dbKey for reference - container.overrideDbKey = dbKey - - -- Function to update override indicators - container.UpdateOverrideIndicators = function(self, currentValue) - -- Debug mode shows all buttons - if S.overrideDebugMode then - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideGlobalText:SetText("(debug)") - self.overrideGlobalText:SetTextColor(1, 0.8, 0.2) -- Yellow for visibility - self.overrideGlobalText:Show() - self.overrideCheckIcon:Hide() - return - end - - -- Only show when in raid mode - local GUI = DF.GUI - if not GUI or GUI.SelectedMode ~= "raid" then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide() - self.overrideCheckIcon:Hide() - return - end - - local AutoProfilesUI = DF.AutoProfilesUI - local isEditing = AutoProfilesUI and AutoProfilesUI:IsEditing() - local isRuntimeOverridden = AutoProfilesUI and AutoProfilesUI:IsOverriddenByRuntime(dbKey) - - -- Hide everything if not editing AND not runtime-overridden - if not isEditing and not isRuntimeOverridden then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideGlobalText:Hide() - self.overrideCheckIcon:Hide() - return - end - - -- Runtime override mode: show star + global value, but no reset button - if isRuntimeOverridden and not isEditing then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting is being overridden by the active auto layout profile. To change it, edit the profile in the Auto Layouts tab."] - self.overrideStar:Show() - self.overrideResetBtn:Hide() -- Can't reset runtime overrides from controls - self.overrideCheckIcon:Hide() - - local globalValue = AutoProfilesUI:GetRuntimeGlobalValue(dbKey) - - -- Format global value for display - local globalDisplay - if type(globalValue) == "boolean" then - globalDisplay = globalValue and L["Yes"] or L["No"] - elseif type(globalValue) == "number" then - if globalValue == math.floor(globalValue) then - globalDisplay = tostring(globalValue) - else - globalDisplay = string.format("%.2f", globalValue) - end - elseif type(globalValue) == "table" then - if globalValue.r then - globalDisplay = L["Color"] - else - globalDisplay = "..." - end - elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then - local mapped = self.overrideOptionsMap[globalValue] - if type(mapped) == "table" then - globalDisplay = mapped.text or mapped.label or globalValue - else - globalDisplay = tostring(mapped) - end - else - globalDisplay = tostring(globalValue or L["None"]) - end - - self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) - self.overrideGlobalText:ClearAllPoints() - self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) - self.overrideGlobalText:Show() - return - end - - -- Editing mode: existing behavior - -- Check if setting is overridden - local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) - local globalValue = AutoProfilesUI:GetGlobalValue(dbKey) - - -- Show/hide star and reset button - if isOverridden then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] - self.overrideStar:Show() - self.overrideResetBtn:Show() - else - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - end - - -- Format global value for display - local globalDisplay - if type(globalValue) == "boolean" then - globalDisplay = globalValue and L["Yes"] or L["No"] - elseif type(globalValue) == "number" then - if globalValue == math.floor(globalValue) then - globalDisplay = tostring(globalValue) - else - globalDisplay = string.format("%.2f", globalValue) - end - elseif type(globalValue) == "table" then - -- Color table - if globalValue.r then - globalDisplay = L["Color"] - else - globalDisplay = "..." - end - elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then - local mapped = self.overrideOptionsMap[globalValue] - if type(mapped) == "table" then - globalDisplay = mapped.text or mapped.label or globalValue - else - globalDisplay = tostring(mapped) - end - else - globalDisplay = tostring(globalValue or L["None"]) - end - - -- Show global value inline with label - self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) - self.overrideGlobalText:ClearAllPoints() - self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) - - if isOverridden then - self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) - self.overrideCheckIcon:Hide() - else - self.overrideGlobalText:SetTextColor(0.3, 0.6, 0.3) - -- Position check icon after text - self.overrideCheckIcon:ClearAllPoints() - self.overrideCheckIcon:SetPoint("LEFT", self.overrideGlobalText, "RIGHT", 2, 0) - self.overrideCheckIcon:Show() - end - self.overrideGlobalText:Show() - end - - -- Register this widget for refresh tracking - table.insert(overrideWidgets, container) - - return container -end -P.AddOverrideIndicators = AddOverrideIndicators - --- Override indicators for order list controls (drag lists) --- These don't have traditional labels, so we use a compact star + reset + "Modified" badge -local function AddOrderListOverrideIndicators(container, dbKey, onReset) - -- Reset button (red, icon-only) + override marker (dot) — shared helpers. - local resetBtn = GUI:CreateOverrideResetButton(container, { - tooltip = L["Reset to Global Order"], - onClick = function() if onReset then onReset() end end, - }) - resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 14) - container.overrideResetBtn = resetBtn - - local starBtn = GUI:CreateOverrideMarker(container) - starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) - container.overrideStar = starBtn - local starIcon = starBtn.icon -- the "Modified" badge below anchors to the dot - - -- "Modified" text to the left of star - local modifiedText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - modifiedText:SetPoint("RIGHT", starIcon, "LEFT", -2, 0) - modifiedText:SetText(L["Modified"]) - modifiedText:SetTextColor(1, 0.8, 0.2, 0.8) - modifiedText:Hide() - container.overrideModifiedText = modifiedText - - -- Store dbKey for reference - container.overrideDbKey = dbKey - - -- Update function - container.UpdateOverrideIndicators = function(self, currentValue) - -- Debug mode - if S.overrideDebugMode then - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideModifiedText:SetText("Modified (debug)") - self.overrideModifiedText:Show() - return - end - - -- Only show when in raid mode and editing - local GUI = DF.GUI - if not GUI or GUI.SelectedMode ~= "raid" then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideModifiedText:Hide() - return - end - - local AutoProfilesUI = DF.AutoProfilesUI - if not AutoProfilesUI or not AutoProfilesUI:IsEditing() then - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideModifiedText:Hide() - return - end - - local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) - - if isOverridden then - self.overrideStar.tooltipText = L["Override active"] - self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] - self.overrideStar:Show() - self.overrideResetBtn:Show() - self.overrideModifiedText:Show() - else - self.overrideStar:Hide() - self.overrideResetBtn:Hide() - self.overrideModifiedText:Hide() - end - end - - -- Register for refresh tracking - table.insert(overrideWidgets, container) -end -P.AddOrderListOverrideIndicators = AddOrderListOverrideIndicators - --- ============================================================ --- SHARED CHECK / RADIO LOOK — single source of truth --- Applies the standard square look to a CheckButton: element --- backdrop + a pixel-snapped, themed WHITE8x8 check. Every box --- and radio (the full builders below AND the hand-rolled ones --- elsewhere) should call this, so a restyle is ONE edit. --- opts.size box size (default 18) --- opts.checkSize check-square size (default 10) --- opts.accent fixed tint {r,g,b} — e.g. ClickCasting's green. --- Omit to follow the party/raid theme (and auto- --- register a theme listener on opts.themeRoot). --- opts.themeRoot frame whose .ThemeListeners drive recolor --- (default the button's parent); only used when --- no accent is given. --- Returns the check texture (also stored as cb.Check). --- ============================================================ -function GUI:StyleCheckButton(cb, opts) - opts = opts or {} - PixelUtil.SetSize(cb, opts.size or 18, opts.size or 18) - CreateElementBackdrop(cb) - - local check = cb.Check or cb:CreateTexture(nil, "OVERLAY") - check:SetTexture("Interface\\Buttons\\WHITE8x8") - local cs = opts.checkSize or 10 - PixelUtil.SetSize(check, cs, cs) - PixelUtil.SetPoint(check, "CENTER", cb, "CENTER", 0, 0) - - local accent = opts.accent - local col = accent or GetThemeColor() - cb.Check = check - -- Native checkboxes let WoW show/hide the check via the checked state; a few - -- consumers (and plain Button-based pseudo-checkboxes) drive it manually via - -- cb.Check:SetShown(). opts.manualCheck supports those without SetCheckedTexture. - if opts.manualCheck then - check:Hide() - else - cb:SetCheckedTexture(check) - end - - -- Hover feedback: a subtle accent wash on the native HIGHLIGHT layer. WoW - -- shows it on mouseover automatically, so it works regardless of any OnEnter - -- the consumer sets (no clobbering), and it doesn't recolor the 1px border - -- (which can render unevenly at fractional UI scales). - local hl = cb:CreateTexture(nil, "HIGHLIGHT") - hl:SetTexture("Interface\\Buttons\\WHITE8x8") - hl:SetAllPoints(cb) - cb.Highlight = hl - - -- Single source for the themed tint: check colour + hover-wash strength. - -- Consumers that drive their own theme refresh call cb.ApplyThemeColor(c) too, - -- so the wash alpha (0.35) is defined in exactly one place. - cb.ApplyThemeColor = function(c) - check:SetVertexColor(c.r, c.g, c.b) - hl:SetVertexColor(c.r, c.g, c.b, 0.35) - end - cb.ApplyThemeColor(col) - - if not accent then - cb.UpdateTheme = function() - cb.ApplyThemeColor(GetThemeColor()) - end - local root = opts.themeRoot or cb:GetParent() - if root then - root.ThemeListeners = root.ThemeListeners or {} - table.insert(root.ThemeListeners, cb) - end - end - return check -end - -function GUI:CreateCheckbox(parent, label, dbTable, dbKey, callback, customGet, customSet, overrideKey) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(220, 24) - container.preferredHeight = GUI.RowHeight.checkbox -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "checkbox" -- /df debug gapcheck groups the spacing report by this - container.fixedRowHeight = true - - local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") - cb:SetPoint("LEFT", 0, 0) - -- Box + themed check come from the shared styler (single source of truth). - GUI:StyleCheckButton(cb, { themeRoot = parent }) - - -- Label - local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - txt:SetText(label) - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - container.label = txt -- exposed so callers can re-font / anchor a subtitle - - -- Determine the key to use for override indicators - local effectiveOverrideKey = overrideKey or dbKey - - -- Add override indicators if we have a key (either dbKey or overrideKey) - if effectiveOverrideKey and type(effectiveOverrideKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(effectiveOverrideKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(effectiveOverrideKey) - cb:SetChecked(globalVal) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - elseif customSet then - customSet(globalVal) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(container, txt, effectiveOverrideKey, onReset, nil, nil, dbTable) - end - - local function UpdateState() - local val = false - if customGet then val = customGet() elseif dbTable and dbKey then val = dbTable[dbKey] end - cb:SetChecked(val) - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - - container:SetScript("OnShow", UpdateState) - -- Re-read the source and repaint the box, for a caller that changed the value - -- behind the widget's back (a "set all" button). Same contract as - -- CreateSegmentToggle:Refresh(); before this, call sites reached for a - -- Hide()/Show() bounce to fire the OnShow above. - container.Refresh = UpdateState - cb:SetScript("OnClick", function(self) - local val = self:GetChecked() - -- Was gated on DF.debugEnabled and printed straight to CHAT, bypassing the - -- console entirely. GUI is the right category and it is already declared. - DF:Debug("GUI", "checkbox OnClick: dbKey=%s overrideKey=%s value=%s", - tostring(dbKey), tostring(overrideKey), tostring(val)) - - -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(effectiveOverrideKey, val) then - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end - return - end - - if customSet then customSet(val) elseif dbTable and dbKey then dbTable[dbKey] = val end - - -- If editing a profile, also set the override (use effectiveOverrideKey) - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and effectiveOverrideKey then - DF.AutoProfilesUI:SetProfileSetting(effectiveOverrideKey, val) - end - - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - - if callback then - DF:Debug("GUI", "checkbox OnClick: calling callback") - callback() - end - if parent.RefreshStates then - DF:Debug("GUI", "checkbox OnClick: calling RefreshStates") - parent:RefreshStates() - end - DF:Debug("GUI", "checkbox OnClick: calling DF:UpdateAll") - DF:UpdateAll() - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget (box + check fill + label) so a disabled CHECKED - -- box greys too: native SetEnabled has no DisabledCheckedTexture, so the - -- accent check would otherwise stay full-bright. - self:SetAlpha(enabled and 1 or 0.4) - cb:SetEnabled(enabled) - if enabled then - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The - -- earlier hit-rect arithmetic here is gone with it — the hit frame is anchored - -- to the FontString, so a label overflowing the fixed 220 container is covered - -- for free rather than by widening the container's hit rect to match. - GUI:AttachTooltip(container, label, txt) - - UpdateState() - - -- SEARCH: Register this setting - if DF.Search then - local hasCustomGetSet = (customGet ~= nil or customSet ~= nil) - if dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterCheckbox(label, dbKey, nil, false, callback) - elseif hasCustomGetSet then - container.searchEntry = DF.Search:RegisterCheckbox(label, nil, nil, true, callback) - end - end - - return container -end - --- ============================================================ --- SEGMENT TOGGLE --- A compact segmented control: the labels sit ON the buttons, all --- of them boxed inside one recessed track so the pair reads as a --- single control rather than two loose buttons. This is the --- "Border Mode: [Shared][Custom]" idiom (AuraDesigner/Options.lua) --- with the track added; use it for short mutually-exclusive values --- that want to sit next to the field they qualify (s / %). --- --- API: GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) --- segments : ordered { value =, label =, tooltip = } — label is what --- shows on the button, tooltip the full name behind a terse one --- opts.segmentWidth (26) / opts.height (18) --- opts.fallbackValue : treated as selected when the stored value matches --- no segment, so an unset key still lights the right button --- opts.customGet / opts.customSet : same convention as CreateCheckbox / --- CreateSlider / CreateDropdown — for a toggle over TRANSIENT UI --- state (or one with its own save path) rather than a db key. Pass --- dbTable/dbKey as nil when using these. --- Returns the container with :Refresh(), :refreshContent() and :SetEnabled(). --- ============================================================ -function GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) - opts = opts or {} - local segW = opts.segmentWidth or 26 - local h = opts.height or 18 - local pad = 1 -- track lip around the buttons - - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - container:SetSize(segW * #segments + pad * 2, h + pad * 2) - CreateElementBackdrop(container) -- the recessed track behind every segment - - -- One read/write pair for both pathways, so the click handler and Refresh - -- can't drift apart. Explicit ifs, not `a and b or c` — a stored value of - -- false/nil is legitimate. - local function GetValue() - if opts.customGet then return opts.customGet() end - if dbTable and dbKey then return dbTable[dbKey] end - end - local function SetValue(v) - if opts.customSet then opts.customSet(v) return true end - if dbTable and dbKey then dbTable[dbKey] = v return true end - return false - end - - local buttons = {} - for i, seg in ipairs(segments) do - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - GUI:StyleButton(btn, { width = segW, height = h, text = seg.label }) - GUI:SetSettingsFont(btn.Text, 9, "") - btn:SetPoint("TOPLEFT", container, "TOPLEFT", pad + (i - 1) * segW, -pad) - btn.value = seg.value - if seg.tooltip then - btn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = seg.tooltip, lines = opts.tooltipLines }) - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - btn:SetScript("OnClick", function(self) - if GetValue() == self.value then return end - if not SetValue(self.value) then return end - container:Refresh() - if callback then callback(self.value) end - end) - buttons[i] = btn - end - - -- Selection: the shared accent border/fill via SetActive, plus a bright/dim - -- label so the state still reads at a glance in a themed accent that is close - -- to the resting border colour. - function container:Refresh() - local cur = GetValue() - local matched = false - for _, b in ipairs(buttons) do if b.value == cur then matched = true end end - if not matched then cur = opts.fallbackValue end - for _, b in ipairs(buttons) do - local on = (b.value == cur) - b:SetActive(on) - if b.Text then - local c = on and C_TEXT or C_TEXT_DIM - b.Text:SetTextColor(c.r, c.g, c.b) - end - end - end - container.refreshContent = function(self) self:Refresh() end - - container.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - for _, b in ipairs(buttons) do b:EnableMouse(enabled) end - end - - container.UpdateTheme = function() container:Refresh() end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, container) - - container:Refresh() - return container -end - - --- ============================================================ --- DEBUG CATEGORY ROW --- A wide row with checkbox + bold category name + description. --- The whole row is clickable, hover shows a background highlight, --- and the description is also surfaced as a tooltip on hover so it --- remains accessible even if it gets visually truncated. --- --- Used by the Debug > Categories sub-tab. The categoryKey writes --- directly to DandersFramesDB_v2.debug.filters. --- ============================================================ --- opts.noisy marks a firehose category: one user action can produce dozens of --- lines, which evicts the trace the log was opened to capture. It renders as the --- shared caution icon with its own tooltip rather than a "(noisy)" suffix baked --- into the description string -- the suffix was untranslatable in place, and it --- competed with the description for the same line of text. -function GUI:CreateDebugCategoryRow(parent, categoryKey, description, width, noisy) - local row = CreateFrame("Frame", nil, parent) - row:SetSize(width or 520, 28) - row:EnableMouse(true) - - -- Hover background - row.hoverBg = row:CreateTexture(nil, "BACKGROUND") - row.hoverBg:SetAllPoints() - row.hoverBg:SetColorTexture(1, 1, 1, 0.05) - row.hoverBg:Hide() - - -- Checkbox - local cb = CreateFrame("CheckButton", nil, row, "BackdropTemplate") - cb:SetPoint("LEFT", 4, 0) - GUI:StyleCheckButton(cb, { size = 16, checkSize = 9, themeRoot = parent }) - cb:EnableMouse(false) -- forward clicks to the row - - -- Category name (bold, full opacity) - local nameTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlight") - nameTxt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - nameTxt:SetWidth(86) - nameTxt:SetJustifyH("LEFT") - nameTxt:SetText(categoryKey) - nameTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Caution icon for a firehose category. Its own hit area, so it can carry a - -- different tooltip from the row without stealing the row's click: the frame - -- only enables mouse, it has no OnMouseUp, so a click still falls through to - -- the row underneath and toggles the category like anywhere else on it. - local noisyIcon - if noisy then - noisyIcon = CreateFrame("Frame", nil, row) - noisyIcon:SetSize(14, 14) - noisyIcon:SetPoint("RIGHT", row, "RIGHT", -8, 0) - noisyIcon:EnableMouse(true) - noisyIcon:SetFrameLevel(row:GetFrameLevel() + 2) - local tex = noisyIcon:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints() - tex:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning") - -- The caution tone's ICON colour, read straight from the shared tone table - -- so this stays in step with every banner and note that uses it. - local ic = INFO_BANNER_TONES.caution.iconColor - tex:SetVertexColor(ic[1], ic[2], ic[3]) - noisyIcon:SetScript("OnEnter", function(self) - -- Keep the row's wash up: the pointer is still over the row, and - -- letting it drop would read as the row losing focus. - row.hoverBg:Show() - GUI:ShowTooltip(self, { - title = L["Noisy category"], - lines = { L["This category can fill the log very quickly, burying the entries you are looking for."], - L["Turn it on only while reproducing the bug it relates to."] }, - tone = "caution", - }) - end) - noisyIcon:SetScript("OnLeave", function() - row.hoverBg:Hide() - GUI:HideTooltip() - end) - row.noisyIcon = noisyIcon - end - - -- Description (dim, fills remaining space, wraps if too long) - if description and description ~= "" then - local descTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - descTxt:SetPoint("LEFT", nameTxt, "RIGHT", 12, 0) - -- Stop short of the icon rather than running under it. - if noisyIcon then - descTxt:SetPoint("RIGHT", noisyIcon, "LEFT", -6, 0) - else - descTxt:SetPoint("RIGHT", row, "RIGHT", -8, 0) - end - descTxt:SetJustifyH("LEFT") - descTxt:SetText(description) - descTxt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - row.descTxt = descTxt - end - - -- State helpers — read/write filters[categoryKey] - -- Absent or true = logged, explicit false = not logged - row.RefreshState = function() - local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters - local checked = (not filters) or filters[categoryKey] ~= false - cb:SetChecked(checked) - end - - local function ToggleState() - local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters - if not filters then return end - -- Toggle: false -> true, anything else -> false - if filters[categoryKey] == false then - filters[categoryKey] = true - else - filters[categoryKey] = false - end - row.RefreshState() - if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end - end - - row:SetScript("OnMouseUp", function(_, button) - if button == "LeftButton" then ToggleState() end - end) - - row:SetScript("OnEnter", function(self) - self.hoverBg:Show() - if description and description ~= "" then - GUI:ShowTooltip(self, { title = categoryKey, lines = { description } }) - end - end) - row:SetScript("OnLeave", function(self) - self.hoverBg:Hide() - GUI:HideTooltip() - end) - - row:SetScript("OnShow", row.RefreshState) - row.RefreshState() - - return row -end - --- The one input "well": translucent-black fill + dim edge. Shared by StyleEditBox --- (the single-line field) and CreateTextArea (the scrolling multi-line container) --- so a text area and a text field read as the same control at two sizes. Passed --- straight to CreateElementBackdrop, which only reads them. -local INPUT_FILL = { 0, 0, 0, 0.5 } -local INPUT_EDGE = { 0.3, 0.3, 0.3, 1 } - --- StyleEditBox: normalize a bare (label-less) EditBox to the standard input --- chrome used by CreateInput/CreateEditBox — translucent-black fill + dim border --- + standard font/insets. The caller still owns size/position/scripts. Pass --- opts.skipFont to keep a custom font (e.g. multi-line / monospace inputs). -function GUI:StyleEditBox(eb, opts) - opts = opts or {} - CreateElementBackdrop(eb, { - bgColor = INPUT_FILL, - borderColor = INPUT_EDGE, - }) - if not opts.skipFont then - eb:SetFontObject(DFFontHighlightSmall) - eb:SetTextInsets(5, 5, opts.multiline and 5 or 0, opts.multiline and 5 or 0) - end - -- Multiline mode: for text areas (export/import blobs, macro bodies). The - -- caller owns the ScrollFrame/sizing; this just flags the editbox + relaxes - -- the vertical insets. Enter inserts a newline (no auto clear-focus). - if opts.multiline then - eb:SetMultiLine(true) - eb:SetAutoFocus(false) - end - return eb -end - -function GUI:CreateInput(parent, label, width) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 180, 44) - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local editbox = CreateFrame("EditBox", nil, frame) - -- Two-corner anchored, so the offset and the height are the ONLY levers -- - -- Nothing corrects a frame's position at runtime, and controls are not - -- position-corrected at all any more. Snap both and all four edges land. - local ebY = SnapLen(editbox, -15) or -15 - editbox:SetPoint("TOPLEFT", 0, ebY) - editbox:SetPoint("TOPRIGHT", 0, ebY) - editbox:SetHeight(SnapLen(editbox, 24) or 24) - GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets - editbox:SetAutoFocus(false) - editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - editbox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) - - -- Grey-when-disabled parity with CreateEditBox (cheap insurance if ever placed - -- in a gated group): dim the whole widget + block editing. - frame.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - editbox:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - frame.EditBox = editbox - -- Tooltip: shared attach on the LABEL only. This factory carried no tooltip - -- support at all, so a caller that set .tooltip on it got silence — - -- Options.lua's custom range-spell input did exactly that, and its - -- explanation never appeared. Keeping it off the edit box also means it can't - -- cover what you are typing. - GUI:AttachTooltip(frame, label, lbl) - return frame -end - --- CreateEditBox: Text input with db binding (for settings like custom text) -function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 180, 44) - frame.preferredHeight = GUI.RowHeight.editbox -- factory-owned slot height (see GUI.RowHeight) - frame.rowKind = "editbox" - frame.fixedRowHeight = true - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - end - if frame.EditBox then - frame.EditBox:SetText(globalVal or "") - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(frame, lbl, dbKey, onReset, 6, nil, dbTable) - end - - local editbox = CreateFrame("EditBox", nil, frame) - -- Two-corner anchored, so the offset and the height are the ONLY levers -- - -- Nothing corrects a frame's position at runtime, and controls are not - -- position-corrected at all any more. Snap both and all four edges land. - local ebY = SnapLen(editbox, -15) or -15 - editbox:SetPoint("TOPLEFT", 0, ebY) - editbox:SetPoint("TOPRIGHT", 0, ebY) - editbox:SetHeight(SnapLen(editbox, 24) or 24) - GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets - editbox:SetAutoFocus(false) - - -- Set initial value from db - if dbTable and dbKey then - editbox:SetText(dbTable[dbKey] or "") - end - - -- Save on enter or focus lost - local function SaveValue() - if dbTable and dbKey then - local val = editbox:GetText() - -- Runtime override protection - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then - if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(val) end - return - end - dbTable[dbKey] = val - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - DF.AutoProfilesUI:SetProfileSetting(dbKey, val) - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(val) - end - if callback then callback() end - end - end - - editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - editbox:SetScript("OnEnterPressed", function(self) - SaveValue() - self:ClearFocus() - end) - editbox:SetScript("OnEditFocusLost", SaveValue) - - -- Optional placeholder: greyed example text shown while the box is empty - -- and unfocused. Purely cosmetic — never written to the db. - if placeholder and placeholder ~= "" then - local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") - ph:SetPoint("LEFT", 5, 0) - ph:SetPoint("RIGHT", -5, 0) - ph:SetJustifyH("LEFT") - ph:SetText(placeholder) - ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) - local function UpdatePlaceholder() - ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") - end - -- Exposed so a caller that puts something INSIDE the box (see - -- GUI:AddEditBoxIcon) can move the placeholder clear of it. - editbox.Placeholder = ph - editbox.UpdatePlaceholder = UpdatePlaceholder - editbox:HookScript("OnTextChanged", UpdatePlaceholder) - editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) - editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) - UpdatePlaceholder() - end - - -- Refresh override indicators on show - frame:SetScript("OnShow", function() - if dbTable and dbKey then - editbox:SetText(dbTable[dbKey] or "") - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end - end) - - -- Grey-when-disabled: the grey loop (RefreshChildStates) calls widget:SetEnabled, - -- but this frame had none, so a disabled group left the input full-bright AND - -- editable. Dim the whole widget + block editing, matching the other helpers. - frame.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - editbox:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - frame.EditBox = editbox - return frame -end - --- ============================================================ --- LEADING ICON INSIDE AN EDIT BOX --- The search-bar look from the main addon search (Features/Search.lua), made --- available to any CreateEditBox rather than re-rolled per search field: the --- glyph, the same 0.72 grey, and — the part that is easy to forget — the text --- inset AND the placeholder both moved clear of it, so neither the typed text --- nor the "Search..." hint runs underneath the icon. --- --- Pass frame.EditBox, not the frame. --- ============================================================ -function GUI:AddEditBoxIcon(editbox, texture, size) - if not editbox or not texture then return end - size = size or 14 - local icon = editbox:CreateTexture(nil, "OVERLAY") - icon:SetSize(size, size) - icon:SetPoint("LEFT", 6, 0) - icon:SetTexture(texture) - icon:SetVertexColor(0.72, 0.72, 0.72) - editbox.Icon = icon - - local left = 6 + size + 5 - local _, right, top, bottom = editbox:GetTextInsets() - editbox:SetTextInsets(left, right, top, bottom) - if editbox.Placeholder then - editbox.Placeholder:SetPoint("LEFT", left, 0) - end - return icon -end - --- ============================================================ --- TEXT AREA — the multi-line cousin of CreateEditBox --- A bordered well holding a scrolling multi-line EditBox. Eight surfaces built --- this same container + ScrollFrame + EditBox stack by hand (export/import blobs, --- the debug log viewer and script runner, the macro body, the popup's input mode, --- the changelog), each picking its own well colour and three of them forgetting --- the click-to-focus, so clicking the empty space below the text did nothing. --- One owner, and the same well as every single-line input. --- --- opts: --- width, height size the well; omit and anchor it yourself --- text initial contents --- fontObject default DFFontHighlightSmall --- fontSize, fontFlags use the settings font at a size instead of a font object --- maxLetters --- readOnly show-and-copy (export strings, the changelog): user --- edits bounce back, but it stays selectable + copyable --- autoFocus take focus and select all on creation (copy-me popups) --- onTextChanged(text, userInput) --- onEscape(editBox) default: clear focus --- bgColor, borderColor override the standard input well --- plain skip the well entirely — for a text area that fills a --- surface which already has its own panel (the changelog) --- insets text insets, default 4 --- Returns the well, with .EditBox / .ScrollFrame and SetText / GetText / --- HighlightText / SetFocus / ClearFocus / SetEnabled forwarded to the field. --- ============================================================ -local TEXTAREA_PAD = 4 -- gap between the well's edge and the scroll frame -local TEXTAREA_GUTTER = 18 -- room right of the scroll for the themed scrollbar - -function GUI:CreateTextArea(parent, opts) - opts = opts or {} - - local well = CreateFrame("Frame", nil, parent, "BackdropTemplate") - if opts.width and opts.height then well:SetSize(opts.width, opts.height) end - local pad = opts.plain and 0 or TEXTAREA_PAD - if not opts.plain then - CreateElementBackdrop(well, { - bgColor = opts.bgColor or INPUT_FILL, - borderColor = opts.borderColor or INPUT_EDGE, - }) - end - - local scroll = CreateFrame("ScrollFrame", nil, well, "ScrollFrameTemplate") - scroll:SetPoint("TOPLEFT", pad, -pad) - scroll:SetPoint("BOTTOMRIGHT", -(pad + TEXTAREA_GUTTER), pad) - StyleScrollBar(scroll) - - local eb = CreateFrame("EditBox", nil, scroll) - eb:SetMultiLine(true) - eb:SetAutoFocus(false) - if opts.fontSize then - GUI:SetSettingsFont(eb, opts.fontSize, opts.fontFlags or "") - else - eb:SetFontObject(opts.fontObject or DFFontHighlightSmall) - end - eb:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - local inset = opts.insets or (opts.plain and 0 or TEXTAREA_PAD) - eb:SetTextInsets(inset, inset, inset, inset) - if opts.maxLetters then eb:SetMaxLetters(opts.maxLetters) end - eb:SetScript("OnEscapePressed", opts.onEscape or function(s) s:ClearFocus() end) - scroll:SetScrollChild(eb) - - -- A scroll child has to be told its size, and that is only known once the - -- well has been sized or its anchors have resolved — which for an anchored - -- (rather than SetSize'd) well is not this frame. So do it here AND on every - -- resize, which is also what lets a text area sit in a resizable window - -- without the caller re-setting the width by hand on every show. - -- - -- Height is seeded ONCE and then left alone: after that the field owns it, - -- growing its own rect as text is added, which is what makes the scroll - -- frame scroll. Re-seeding on resize would clamp a grown field back down. - local heightSeeded = false - local function SyncSize(w, h) - if w and w > 0 then eb:SetWidth(w) end - if h and h > 0 and not heightSeeded then - heightSeeded = true - eb:SetHeight(h) - end - end - scroll:SetScript("OnSizeChanged", function(_, w, h) SyncSize(w, h) end) - SyncSize(scroll:GetWidth(), scroll:GetHeight()) - - -- Clicking anywhere in the well lands in the field, not just on the text - -- itself — the contents rarely fill the box. - well:EnableMouse(true) - well:SetScript("OnMouseDown", function() eb:SetFocus() end) - scroll:EnableMouse(true) - scroll:SetScript("OnMouseDown", function() eb:SetFocus() end) - - well.EditBox, well.ScrollFrame = eb, scroll - well.GetText = function(_) return eb:GetText() end - well.HighlightText = function(_, ...) eb:HighlightText(...) end - well.SetFocus = function(_) eb:SetFocus() end - well.ClearFocus = function(_) eb:ClearFocus() end - well.SetText = function(_, text) - eb:SetText(text or "") - eb:SetCursorPosition(0) -- long blobs open at the top, not the tail - end - - -- Grey-when-disabled, per the GUI conventions: dim the whole widget AND stop - -- it accepting edits. - well.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - eb:SetEnabled(enabled) - end - - -- WoW has no read-only EditBox. Bouncing the text back on any USER change - -- keeps Ctrl+A / Ctrl+C working, which EnableKeyboard(false) would not. - if opts.readOnly then - local locked = opts.text or "" - eb:SetScript("OnTextChanged", function(s, user) - if user and s:GetText() ~= locked then - s:SetText(locked) - s:HighlightText() - end - end) - well.SetText = function(_, text) - locked = text or "" - eb:SetText(locked) - eb:SetCursorPosition(0) - end - elseif opts.onTextChanged then - eb:SetScript("OnTextChanged", function(s, user) - opts.onTextChanged(s:GetText(), user) - end) - end - - if opts.text then well:SetText(opts.text) end - if opts.autoFocus then - eb:SetAutoFocus(true) - eb:SetFocus() - eb:HighlightText() - end - - return well -end - --- customGet / customSet (optional, matches CreateDropdown's pattern): when --- provided, the slider routes its reads and writes through these functions --- instead of dbTable[dbKey] directly. Used by widgets whose underlying value --- lives inside a nested table (e.g. Border Alpha → BorderColor.a), --- where the plain `dbTable[dbKey] = v` path can't express the nesting. --- Consumers that pass customSet typically pass dbKey = nil so the --- auto-profile override system doesn't track a key that doesn't exist at the --- top level of dbTable. --- accentColor (optional {r,g,b}): fixed thumb/fill colour instead of the mode --- theme — for ClickCasting (green) / Search (blue) which keep their identity. - -function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, callback, lightweightUpdate, usePreviewMode, customGet, customSet, accentColor) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 50) - container.preferredHeight = GUI.RowHeight.slider -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "slider" - container.fixedRowHeight = true - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided (for auto profiles) - -- Use vertical offset of 6 to align with label row (sliders have input box below) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - dbTable[dbKey] = globalVal - -- Update slider display - if container.slider then - container.slider:SetValue(globalVal) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) - end - - -- Background track. - -- Left edge and height here; the RIGHT edge is pinned to the value box further - -- down, once that exists. The track used to be a fixed 180px while a dropdown - -- anchors TOPLEFT+TOPRIGHT and fills its container, so on any panel wider than - -- the 260 default the two controls ended at visibly different x positions -- - -- and drifted further apart the wider the panel got. Both are container-driven - -- now, so they line up at any width instead of at one magic number. - local track = CreateFrame("Frame", nil, container, "BackdropTemplate") - track:SetPoint("TOPLEFT", 0, SnapLen(track, -18) or -18) - track:SetHeight(SnapLen(track, 8) or 8) - CreateElementBackdrop(track) - - -- Fill track (colored portion) - local fill = track:CreateTexture(nil, "ARTWORK") - fill:SetPoint("LEFT", 1, 0) - fill:SetHeight(6) - local c = accentColor or GetThemeColor() - fill:SetColorTexture(c.r, c.g, c.b, 0.8) - - -- Slider - local slider = CreateFrame("Slider", nil, container) - -- Same snapped offset/height as the track it sits on, or the invisible hit - -- area drifts off the visible bar by a fraction of a pixel. - slider:SetPoint("TOPLEFT", 0, SnapLen(slider, -18) or -18) - slider:SetHeight(SnapLen(slider, 8) or 8) -- right edge pinned to the value box, same as the track - slider:SetOrientation("HORIZONTAL") - slider:SetMinMaxValues(minVal, maxVal) - slider:SetValueStep(step) - slider:SetObeyStepOnDrag(true) - slider:SetHitRectInsets(-4, -4, -8, -8) - container.slider = slider -- Store reference for reset - - -- Track whether this slider is actively being dragged - local isDragging = false - - -- Store preview mode flag for this slider - local sliderUsePreviewMode = usePreviewMode or false - - -- Thumb - local thumb = slider:CreateTexture(nil, "OVERLAY") - thumb:SetSize(12, 16) - thumb:SetColorTexture(c.r, c.g, c.b, 1) - slider:SetThumbTexture(thumb) - - -- Value input - local input = CreateFrame("EditBox", nil, container, "BackdropTemplate") - input:SetSize(50, 20) - -- Pinned to the container's RIGHT edge -- the same edge a dropdown's opener - -- ends on -- and the track/slider then stretch from the left to meet it. - -- y = -12 keeps the 20px box centred on the 8px track at -18. - input:SetPoint("TOPRIGHT", 0, -12) - track:SetPoint("RIGHT", input, "LEFT", -8, 0) - slider:SetPoint("RIGHT", input, "LEFT", -8, 0) - CreateElementBackdrop(input) - input:SetFontObject(DFFontHighlightSmall) - input:SetJustifyH("CENTER") - input:SetAutoFocus(false) - input:SetTextInsets(2, 2, 0, 0) - - local function UpdateFill() - local val = slider:GetValue() - local pct = (val - minVal) / (maxVal - minVal) - -- Measured off the LIVE track, not the old hardcoded 178 (= the fixed 180 - -- track minus the fill's 1px inset each side). The track stretches now, so - -- a constant here would under-fill on any panel wider than the default. - local usable = (track:GetWidth() or 0) - 2 - if usable < 1 then usable = 1 end - fill:SetWidth(math.max(1, pct * usable)) - end - -- The track's width is only known once the page layout has resolved its - -- anchors, and changes again if the panel is resized -- so repaint the fill - -- whenever it does, or the bar renders at its pre-layout width. - track:SetScript("OnSizeChanged", function() UpdateFill() end) - - container.SetEnabled = function(self, enabled) - slider:SetEnabled(enabled) - -- Grey the numeric value box too: it was only EnableMouse'd (clicks blocked - -- but still full-bright + typeable), so it stayed lit while the track dimmed. - input:EnableMouse(enabled) - input:SetEnabled(enabled) - input:SetAlpha(enabled and 1 or 0.4) - local tc = accentColor or GetThemeColor() - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - thumb:SetColorTexture(tc.r, tc.g, tc.b, 1) - fill:SetColorTexture(tc.r, tc.g, tc.b, 0.8) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - thumb:SetColorTexture(0.4, 0.4, 0.4, 1) - fill:SetColorTexture(0.4, 0.4, 0.4, 0.5) - end - end - - container.UpdateTheme = function() - local nc = accentColor or GetThemeColor() - if slider:IsEnabled() then - thumb:SetColorTexture(nc.r, nc.g, nc.b, 1) - fill:SetColorTexture(nc.r, nc.g, nc.b, 0.8) - end - end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, container) - - local suppressCallback = false - - -- Smart format: show whole numbers as integers, decimals with minimum precision needed - local function FormatValue(val) - if val == math.floor(val) then - return string.format("%d", val) - elseif val * 10 == math.floor(val * 10) then - return string.format("%.1f", val) - else - return string.format("%.2f", val) - end - end - - -- Wrapper for both pathways: customGet/Set when provided, dbTable[dbKey] - -- otherwise. Centralising this avoids a sprinkling of `if customGet then` - -- across every place the slider touches its value. - local function ReadValue() - if customGet then return customGet() end - if dbTable then return dbTable[dbKey] end - return nil - end - local function WriteValue(v) - if customSet then return customSet(v) end - if dbTable then dbTable[dbKey] = v end - end - - local function UpdateValue(val) - val = val or minVal - suppressCallback = true - slider:SetValue(val) - suppressCallback = false - input:SetText(FormatValue(val)) - UpdateFill() - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - - -- Re-read the bound value (customGet or dbTable[dbKey]) and redraw. For a slider whose - -- source can change under it — e.g. one bound through customGet to whichever key an - -- account-wide unit dial currently selects — called from refreshContent. - container.RefreshValue = function(self) - local v = ReadValue() - if v ~= nil then UpdateValue(math.max(minVal, math.min(maxVal, v))) end - end - -- Runtime range. A slider whose UNIT is decided elsewhere (seconds vs percent) is built - -- once but must re-scale in place, or flipping that dial leaves a 1-60 track in front of - -- a percentage until the panel is rebuilt. Pair with container.label for the caption. - container.SetRange = function(self, newMin, newMax) - if newMin ~= minVal or newMax ~= maxVal then - minVal, maxVal = newMin, newMax - slider:SetMinMaxValues(minVal, maxVal) - end - self:RefreshValue() - end - - -- Track drag start - pass the lightweight update function, name for debug, and preview mode - slider:SetScript("OnMouseDown", function(self, button) - if button == "LeftButton" then - isDragging = true - local funcName = lightweightUpdate and ((dbKey or label or "slider") .. " lightweight") or nil - DF:OnSliderDragStart(lightweightUpdate, funcName, sliderUsePreviewMode) - end - end) - - -- Track drag end - do full update when slider is released - slider:SetScript("OnMouseUp", function(self, button) - if button == "LeftButton" and isDragging then - isDragging = false - DF:OnSliderDragStop() - -- Update override indicators after drag ends - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(slider:GetValue()) - end - end - end) - - slider:SetScript("OnShow", function() - local v = ReadValue() - if v ~= nil then UpdateValue(v) end - end) - - slider:SetScript("OnValueChanged", function(self, value) - if suppressCallback then return end - if not (dbTable or customSet) then return end - if step >= 1 then - value = math.floor(value + 0.5) - else - value = math.floor(value / step + 0.5) * step - end - - -- Runtime override protection: redirect to baseline, skip refresh - if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, value) then - if not input:HasFocus() then input:SetText(FormatValue(value)) end - UpdateFill() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(value) end - return - end - - WriteValue(value) - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, value) - end - - if not input:HasFocus() then - input:SetText(FormatValue(value)) - end - UpdateFill() - -- Use targeted update system - lightweight during drag, full on release - DF:ThrottledUpdateAll() - -- Skip callback during drag - it will run via UpdateAll on release - if callback and not DF.sliderDragging then - callback() - end - end) - - input:SetScript("OnEnterPressed", function(self) - local val = tonumber(self:GetText()) - if val then - val = math.max(minVal, math.min(maxVal, val)) - - -- Runtime override protection: redirect to baseline, skip refresh - if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then - self:SetText(FormatValue(val)) - suppressCallback = true - slider:SetValue(val) - suppressCallback = false - UpdateFill() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end - self:ClearFocus() - return - end - - WriteValue(val) - suppressCallback = true - slider:SetValue(val) - suppressCallback = false - - -- Update input text to show actual value entered - self:SetText(FormatValue(val)) - UpdateFill() - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, val) - end - - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - - -- FIX 2025-01-20: Call callback OR lightweightUpdate (some sliders have nil callback) - if callback then - callback() - elseif lightweightUpdate then - lightweightUpdate() - end - - -- Guaranteed full update (SetValue may not fire OnValueChanged if value didn't change) - DF:UpdateAll() - else - local v = ReadValue(); if v ~= nil then UpdateValue(v) end - end - self:ClearFocus() - end) - - input:SetScript("OnEscapePressed", function(self) - local v = ReadValue(); if v ~= nil then UpdateValue(v) end - self:ClearFocus() - end) - - local initial = ReadValue() - if initial ~= nil then UpdateValue(initial) end - - -- SEARCH: Register this setting with slider metadata - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterSlider(label, dbKey, minVal, maxVal, step, nil, callback) - end - - -- Expose label for dynamic updates - container.label = lbl - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). Keeping it - -- off the bar matters most here — a tooltip over a slider you are dragging is - -- the worst case of the problem. Both .tooltip (title from the label) and the - -- legacy .tooltipText/.tooltipSubText pair are honoured. - GUI:AttachTooltip(container, label, lbl) - - return container -end - --- Stamp the shared Frame Level explanation onto a slider. One helper rather than the same --- two strings at 21 call sites, and it keeps the wording in ONE place -- the old per-page --- label went stale the moment the scale changed (it still read "0=Auto" afterwards). --- Takes the CONTAINER that CreateSlider returns, which is what every call site has. -function GUI:SetFrameLevelTooltip(container) - if not container then return end - container.tooltipText = L["Frame Level"] - container.tooltipSubText = L["Higher numbers draw on top of lower ones. Every Frame Level in DandersFrames uses the same scale, counted up from the unit frame, so you can compare them directly."] - return container -- chainable, so it wraps a CreateSlider call in place -end - --- Dual-handle range slider: two draggable handles select a [lo, hi] sub-range of --- [minRange, maxRange]. Self-contained — the caller anchors the returned track --- frame and reads values via the onChange callback. (:GetValues() also exists and --- completes the SetValues pair, but no current consumer polls it.) Drag is --- tracked on the track's own OnUpdate (no dependence on parent scripts), and a --- mouse-button check releases the drag even if the cursor leaves the handle. --- opts: --- width(336), accent({r,g,b}=theme), minRange, maxRange, lo, hi, --- scaleLabels({...} optional tick labels), scaleMin/scaleMax (label scale, --- default minRange/maxRange — lets ticks stay on a fixed scale while the --- handle range changes), display(FontString updated each change), --- formatRange(fn(lo,hi)->str), formatOne(fn(v)->str), --- onChange(fn(lo,hi) — fired on user-driven changes only, not SetValues). --- Methods on the returned frame: :SetRange(min,max), :SetValues(lo,hi), --- :GetValues()->lo,hi. -function GUI:CreateRangeSlider(parent, opts) - opts = opts or {} - local width = opts.width or 336 - local accent = opts.accent or GetThemeColor() - - local track = CreateFrame("Frame", nil, parent, "BackdropTemplate") - track:SetSize(width, 12) - CreateElementBackdrop(track, { - bgColor = { 0.03, 0.03, 0.03, 1 }, - borderColor = { 0.2, 0.2, 0.2, 1 }, - }) - - track.minRange = opts.minRange or 1 - track.maxRange = opts.maxRange or 40 - track.lo = opts.lo or track.minRange - track.hi = opts.hi or track.maxRange - - local rangeFill = track:CreateTexture(nil, "ARTWORK") - rangeFill:SetTexture("Interface\\Buttons\\WHITE8x8") - rangeFill:SetVertexColor(accent.r, accent.g, accent.b, 0.5) - rangeFill:SetHeight(10) - rangeFill:SetPoint("TOP", 0, -1) - - local function MakeHandle() - local h = CreateFrame("Button", nil, track) - h:SetSize(8, 16) - h:EnableMouse(true) - local tex = h:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints() - tex:SetTexture("Interface\\Buttons\\WHITE8x8") - tex:SetVertexColor(accent.r, accent.g, accent.b, 1) - return h - end - local minHandle, maxHandle = MakeHandle(), MakeHandle() - track.minHandle, track.maxHandle = minHandle, maxHandle - - local function ValueToPos(value) - local pct = (value - track.minRange) / (track.maxRange - track.minRange) - return pct * (width - 4) + 2 - end - local function PosToValue(pos) - local pct = (pos - 2) / (width - 4) - return math.floor(pct * (track.maxRange - track.minRange) + track.minRange + 0.5) - end - - local function Redraw() - local minPos, maxPos = ValueToPos(track.lo), ValueToPos(track.hi) - minHandle:ClearAllPoints() - minHandle:SetPoint("CENTER", track, "LEFT", minPos, 0) - maxHandle:ClearAllPoints() - maxHandle:SetPoint("CENTER", track, "LEFT", maxPos, 0) - rangeFill:ClearAllPoints() - rangeFill:SetPoint("LEFT", track, "LEFT", minPos, 0) - rangeFill:SetWidth(math.max(maxPos - minPos, 2)) - if opts.display then - if track.lo == track.hi then - opts.display:SetText(opts.formatOne and opts.formatOne(track.lo) or tostring(track.lo)) - else - opts.display:SetText(opts.formatRange and opts.formatRange(track.lo, track.hi) - or (track.lo .. " - " .. track.hi)) - end - end - end - - local dragging = nil - local function ApplyCursor() - local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() - local trackLeft = track:GetLeft() - if not trackLeft then return end - local pos = math.max(2, math.min(x - trackLeft, width - 2)) - local value = math.max(track.minRange, math.min(PosToValue(pos), track.maxRange)) - if dragging == "min" then - if value <= track.hi then track.lo = value end - elseif dragging == "max" then - if value >= track.lo then track.hi = value end - end - Redraw() - if opts.onChange then opts.onChange(track.lo, track.hi) end - end - - minHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "min" end end) - maxHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "max" end end) - track:SetScript("OnUpdate", function() - if not dragging then return end - if not IsMouseButtonDown("LeftButton") then dragging = nil; return end - ApplyCursor() - end) - - track:EnableMouse(true) - track:SetScript("OnMouseDown", function(_, b) - if b ~= "LeftButton" then return end - local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() - local trackLeft = track:GetLeft() - if not trackLeft then return end - local value = PosToValue(x - trackLeft) - if math.abs(value - track.lo) <= math.abs(value - track.hi) then - if value <= track.hi then track.lo = math.max(track.minRange, value) end - else - if value >= track.lo then track.hi = math.min(track.maxRange, value) end - end - Redraw() - if opts.onChange then opts.onChange(track.lo, track.hi) end - end) - - if opts.scaleLabels then - local sMin = opts.scaleMin or track.minRange - local sMax = opts.scaleMax or track.maxRange - for _, num in ipairs(opts.scaleLabels) do - local pct = (num - sMin) / (sMax - sMin) - local xPos = pct * (width - 4) + 2 - local lbl = track:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetText(num) - lbl:SetTextColor(0.35, 0.35, 0.35) - lbl:SetPoint("TOP", track, "BOTTOM", xPos - width / 2, -2) - end - end - - function track:SetRange(minR, maxR) - self.minRange, self.maxRange = minR, maxR - self.lo = math.max(minR, math.min(self.lo, maxR)) - self.hi = math.max(minR, math.min(self.hi, maxR)) - Redraw() - end - function track:SetValues(lo, hi) - self.lo = math.max(self.minRange, math.min(lo, self.maxRange)) - self.hi = math.max(self.minRange, math.min(hi, self.maxRange)) - Redraw() - end - function track:GetValues() return self.lo, self.hi end - - Redraw() - return track -end - -function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback, lightweightCallback, useLightweight) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 28) - container.preferredHeight = GUI.RowHeight.colorpicker -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "colorpicker" - container.fixedRowHeight = true - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, 0) - btn:SetPoint("TOPRIGHT", 0, 0) - btn:SetHeight(SnapLen(btn, 24) or 24) - CreateElementBackdrop(btn) - - -- Label - local txt = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", 8, 0) - txt:SetText(label) - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Color swatch - local swatch = btn:CreateTexture(nil, "OVERLAY") - swatch:SetSize(40, 16) - swatch:SetPoint("RIGHT", -6, 0) - - -- Add override indicators if dbKey is provided (for auto profiles) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if globalVal then - dbTable[dbKey].r = globalVal.r - dbTable[dbKey].g = globalVal.g - dbTable[dbKey].b = globalVal.b - dbTable[dbKey].a = globalVal.a or 1 - end - if container.UpdateSwatch then - container:UpdateSwatch() - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable[dbKey]) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, txt, dbKey, onReset, nil, nil, dbTable) - end - - local function UpdateSwatch() - if dbTable and dbKey and dbTable[dbKey] then - local c = dbTable[dbKey] - swatch:SetColorTexture(c.r, c.g, c.b, c.a or 1) - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(c) - end - end - end - container.UpdateSwatch = UpdateSwatch -- Expose for reset - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - -- Tooltip: shared attach on the LABEL only. This factory carried none, across - -- 87 colour pickers. The btn keeps its own hover scripts untouched — the hit - -- frame is over the text, not the swatch. - GUI:AttachTooltip(container, label, txt) - - btn:SetScript("OnClick", function() - if not dbTable then return end - local c = dbTable[dbKey] - if not c then - c = {r = 1, g = 1, b = 1, a = 1} - dbTable[dbKey] = c - end - - -- Store original values for cancel - local originalColor = {r = c.r, g = c.g, b = c.b, a = c.a or 1} - - -- Blizzard's SetupColorPickerAndShow fires swatchFunc once DURING - -- setup (its SetColorRGB triggers OnColorSelect — the source comments - -- it). That spurious fire re-writes the unchanged colour and runs the - -- change callbacks on mere open: it commits per-element override flags - -- (Text Designer) and triggers a pointless full refresh. Suppress - -- callbacks until setup has returned. - local settingUp = true - - local info = { - swatchFunc = function() - if settingUp then return end - local r, g, b = ColorPickerFrame:GetColorRGB() - local a = 1 - if hasAlpha and ColorPickerFrame.GetColorAlpha then - a = ColorPickerFrame:GetColorAlpha() or 1 - end - dbTable[dbKey].r = r - dbTable[dbKey].g = g - dbTable[dbKey].b = b - dbTable[dbKey].a = a - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, {r = r, g = g, b = b, a = a}) - end - - UpdateSwatch() - -- Use lightweight callback during dragging if available - if useLightweight and lightweightCallback then - lightweightCallback() - else - DF:ThrottledUpdateAll() - if callback then callback() end - end - end, - hasOpacity = hasAlpha, - opacityFunc = hasAlpha and function() - if settingUp then return end - if ColorPickerFrame.GetColorAlpha then - local a = ColorPickerFrame:GetColorAlpha() - if a then - dbTable[dbKey].a = a - UpdateSwatch() - -- Use lightweight callback during dragging if available - if useLightweight and lightweightCallback then - lightweightCallback() - else - DF:ThrottledUpdateAll() - if callback then callback() end - end - end - end - end or nil, - cancelFunc = function(restore) - -- Restore original color on cancel - dbTable[dbKey].r = originalColor.r - dbTable[dbKey].g = originalColor.g - dbTable[dbKey].b = originalColor.b - dbTable[dbKey].a = originalColor.a - UpdateSwatch() - DF:UpdateAll() - if callback then callback() end - end, - r = c.r or 1, - g = c.g or 1, - b = c.b or 1, - opacity = hasAlpha and (c.a or 1) or nil, - } - - -- Hook the OK button to run full update when confirmed - if useLightweight and lightweightCallback then - -- We need to run full update when picker is closed via OK - -- Use a frame to detect when color picker closes - if not container.colorPickerWatcher then - container.colorPickerWatcher = CreateFrame("Frame") - end - container.colorPickerWatcher:SetScript("OnUpdate", function(self) - if not ColorPickerFrame:IsShown() then - self:SetScript("OnUpdate", nil) - -- Only run if color changed (not cancelled) - local cur = dbTable[dbKey] - if cur.r ~= originalColor.r or cur.g ~= originalColor.g or - cur.b ~= originalColor.b or cur.a ~= originalColor.a then - DF:UpdateAll() - if callback then callback() end - end - end - end) - end - - -- Attach default colour so the picker can offer a Default button - -- dbTable.__dfDefaults is set by callers (e.g. Aura Designer proxies) that - -- store their defaults outside DF.PartyDefaults / DF.RaidDefaults. Read via - -- rawget so proxies' __index doesn't see this lookup as a regular setting. - local defaultVal = (dbTable and rawget(dbTable, "__dfDefaults") and dbTable.__dfDefaults[dbKey]) - or (DF.PartyDefaults and DF.PartyDefaults[dbKey]) - or (DF.RaidDefaults and DF.RaidDefaults[dbKey]) - -- Fallback: power bar colours use WoW's PowerBarColor table as their default - if not defaultVal and PowerBarColor and dbKey then - defaultVal = PowerBarColor[dbKey] - end - if defaultVal and type(defaultVal) == "table" and defaultVal.r then - info.dfDefaultColor = {r = defaultVal.r or 1, g = defaultVal.g or 1, b = defaultVal.b or 1, a = defaultVal.a or 1} - -- Populate ElvUI's "Default" button (ColorPPDefault) so it enables and - -- pastes the DF setting default when the native Blizzard picker is shown - local elvDefault = _G["ColorPPDefault"] - if elvDefault then - elvDefault.colors = info.dfDefaultColor - end - end - - -- Mark this as a DandersFrames color picker call - GUI:MarkColorPickerCall() - ColorPickerFrame:SetupColorPickerAndShow(info) - settingUp = false - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so the colour swatch greys even when it's a dark - -- colour (SetDesaturated alone is invisible on near-black swatches). - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - swatch:SetDesaturated(false) - else - txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - swatch:SetDesaturated(true) - end - end - - btn:SetScript("OnShow", UpdateSwatch) - UpdateSwatch() - - -- SEARCH: Register this setting - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterColorPicker(label, dbKey, hasAlpha, nil, callback) - end - - return container -end - --- ============================================================ --- OPEN-MENU REGISTRY --- Dropdown/preset menus anchor to their button and survive context switches --- that leave the button visible (e.g. the Aura Designer changing tabs). --- Every menu frame registers here at creation; CloseAllMenus() lets a --- context switch dismiss whatever is open. --- ============================================================ -GUI._menus = GUI._menus or {} -function GUI:RegisterMenu(frame) self._menus[frame] = true end -function GUI:CloseAllMenus() - for f in pairs(self._menus) do - if f:IsShown() then f:Hide() end - end -end - -function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, customGet, customSet, opts) - opts = opts or {} - local accentColor = opts.accent - local optionsFunc = opts.optionsFunc - - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, opts.inline and 24 or 50) - -- Inline dropdowns embed in a caller-managed layout (label hidden), so only the standalone - -- form owns a fixed slot height; inline keeps whatever height its host passes. - if not opts.inline then - container.preferredHeight = GUI.RowHeight.dropdown -- factory-owned slot (see GUI.RowHeight) - container.rowKind = "dropdown" - container.fixedRowHeight = true - end - - -- Label - local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - -- Expose label so helpers like AddSectionNewBadge can anchor a badge to it. - container.label = lbl - if opts.inline then - lbl:Hide() - end - - -- Add override indicators if dbKey is provided (for auto profiles) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - dbTable[dbKey] = globalVal - if container.UpdateText then - container:UpdateText() - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, lbl, dbKey, onReset, 6, options, dbTable) - end - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - if opts.inline then - -- Fill the container so the caller's SetSize(w, h) controls the opener - -- size + vertical centering (inline callers add their own left label and - -- size the container to match the surrounding row, e.g. 140x18 / 110x16). - btn:SetAllPoints(container) - else - local dY = SnapLen(btn, -16) or -16 - btn:SetPoint("TOPLEFT", 0, dY) - btn:SetPoint("TOPRIGHT", 0, dY) - btn:SetHeight(SnapLen(btn, 24) or 24) - end - CreateElementBackdrop(btn) - - btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - btn.Text:SetPoint("LEFT", 8, 0) - btn.Text:SetPoint("RIGHT", -20, 0) - btn.Text:SetJustifyH("LEFT") - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Arrow indicator - local arrow = btn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -8, 0) - arrow:SetSize(12, 12) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- SetDisplayOverride: a fixed opener caption that wins over the selected - -- option's text (e.g. a disabled dropdown explaining WHY it's disabled, - -- like the Aura Designer spec dropdown's "shared across specs" state). - -- nil clears the override and restores the selected option's text. - local displayOverride - local function UpdateText() - if displayOverride then - btn.Text:SetText(displayOverride) - return - end - if customGet or (dbTable and dbKey) then - local val = customGet and customGet() or dbTable[dbKey] - local displayVal = options[val] - -- Handle table format: {value = X, text = "text"} or {text = "text"} - local optColor - if type(displayVal) == "table" then - optColor = displayVal.color - displayVal = displayVal.text or displayVal.label or tostring(val) - end - btn.Text:SetText(displayVal or tostring(val) or L["Select..."]) - -- Selected label mirrors its option row's colour when the option - -- carries one (e.g. class-coloured specs); plain options reset to - -- the standard text colour. Skipped while disabled so the - -- grey-when-disabled treatment from SetEnabled stays intact. - if btn:IsEnabled() then - local tc = optColor or C_TEXT - btn.Text:SetTextColor(tc.r, tc.g, tc.b) - end - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - end - container.UpdateText = UpdateText -- Expose for reset - container.SetDisplayOverride = function(self, text) - displayOverride = text - UpdateText() - end - - -- Menu frame - -- Menus hang from the opener's LEFT edge by default, so a wider-than-opener - -- menu spills rightward. opts.menuAlign = "RIGHT" pins the menu's TOPRIGHT - -- to the opener's BOTTOMRIGHT instead (surplus width grows leftward) — for - -- openers sitting near a right edge, e.g. the Aura Designer spec dropdown. - local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") - if opts.menuAlign == "RIGHT" then - menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) - else - menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) - end - menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menuFrame) - menuFrame:SetClampedToScreen(true) - CreateElementBackdrop(menuFrame) - menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) - menuFrame:Hide() - - -- Searchable menus (opts.searchable): a search box pinned above a scrollable - -- item list, mirroring the font/sound dropdowns. Option sets may also carry - -- non-clickable group-header rows — `_order` entries whose option value is - -- { header = true, text = ..., color = ... } (e.g. class-coloured spec - -- groups). While filtering, a header only stays visible if at least one of - -- its options matches. - local searchable = opts.searchable - local ITEM_HEIGHT = 22 - local SEARCH_HEIGHT = 26 - local MAX_VISIBLE = opts.maxVisible or 12 - local searchBox, scrollFrame, scrollChild, searchPlaceholder - if searchable then - searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") - searchBox:SetPoint("TOPLEFT", 4, -4) - searchBox:SetPoint("TOPRIGHT", -4, -4) - searchBox:SetHeight(22) - searchBox:SetAutoFocus(false) - searchBox:SetFontObject(DFFontHighlightSmall) - searchBox:SetTextInsets(24, 8, 0, 0) - CreateElementBackdrop(searchBox) - searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) - - local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") - searchIcon:SetPoint("LEFT", 6, 0) - searchIcon:SetSize(12, 12) - searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") - searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - searchPlaceholder:SetPoint("LEFT", 24, 0) - searchPlaceholder:SetText(L["Search..."]) - searchPlaceholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) - - searchBox:SetScript("OnEditFocusGained", function() searchPlaceholder:Hide() end) - searchBox:SetScript("OnEditFocusLost", function() - if searchBox:GetText() == "" then searchPlaceholder:Show() end - end) - searchBox:SetScript("OnEscapePressed", function() menuFrame:Hide() end) - - scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) - scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) - scrollChild = CreateFrame("Frame", nil, scrollFrame) - scrollChild:SetWidth(200) -- resized to the menu width on each rebuild - scrollFrame:SetScrollChild(scrollChild) - StyleScrollBar(scrollFrame) - end - - -- Clear tracking when hidden - menuFrame:SetScript("OnHide", function() - if S.currentOpenDropdown == menuFrame then - S.currentOpenDropdown = nil - end - if searchBox then - searchBox:SetText("") - searchBox:ClearFocus() - searchPlaceholder:Show() - end - end) - - local menuButtons = {} - local menuHeight = 0 - local sortedOptions = {} - - -- Build (or rebuild) the menu buttons from the current `options` upvalue. - -- Rows are POOLED (frames can't be garbage-collected) — rebuilds reuse - -- existing buttons and hide the surplus, so dynamic/searchable dropdowns - -- don't leak a row set per rebuild. Static callers build exactly once below. - local menuContentW = 0 -- widest item text; sizes the menu to fit long options - local function BuildMenuButtons(filterText) - for _, b in ipairs(menuButtons) do b:Hide() end - wipe(sortedOptions) - menuHeight = 0 - - -- Collect the ordered option list (header rows ride along) - local ordered = {} - -- Check for custom order array - if options._order then - -- Use specified order - for _, k in ipairs(options._order) do - local v = options[k] - if v then - -- Handle both formats: KEY = "text" or KEY = {value=, text=, color=, header=} - local isTable = type(v) == "table" - table.insert(ordered, { - key = k, - value = isTable and (v.text or v.label or tostring(k)) or v, - color = isTable and v.color or nil, - header = isTable and v.header or nil, - }) - end - end - else - -- Default: sort alphabetically by display value - for k, v in pairs(options) do - local isTable = type(v) == "table" - table.insert(ordered, { - key = k, - value = isTable and (v.text or v.label or tostring(k)) or v, - color = isTable and v.color or nil, - header = isTable and v.header or nil, - }) - end - table.sort(ordered, function(a, b) - local aVal = type(a.value) == "string" and a.value or tostring(a.key) - local bVal = type(b.value) == "string" and b.value or tostring(b.key) - return aVal < bVal - end) - end - - -- Apply the search filter (searchable menus): match option display text; - -- keep a group header only when one of its options survives. - local filter = filterText and filterText ~= "" and filterText:lower() or nil - if filter then - local pendingHeader - for _, opt in ipairs(ordered) do - if opt.header then - pendingHeader = opt - else - local txt = type(opt.value) == "string" and opt.value:lower() or tostring(opt.key):lower() - if txt:find(filter, 1, true) then - if pendingHeader then - table.insert(sortedOptions, pendingHeader) - pendingHeader = nil - end - table.insert(sortedOptions, opt) - end - end - end - else - for _, opt in ipairs(ordered) do - table.insert(sortedOptions, opt) - end - end - - local itemParent = scrollChild or menuFrame - local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) - local selColor = accentColor or GetThemeColor() - -- Once a group header has appeared, subsequent option rows indent under - -- it (menus without headers keep the flat 8px inset). - local seenHeader = false - - for i, opt in ipairs(sortedOptions) do - local menuBtn = menuButtons[i] - if not menuBtn then - menuBtn = CreateFrame("Button", nil, itemParent) - menuBtn:SetPoint("TOPLEFT", 2, -2 - (i - 1) * ITEM_HEIGHT) - menuBtn:SetPoint("TOPRIGHT", -2, -2 - (i - 1) * ITEM_HEIGHT) - menuBtn:SetHeight(ITEM_HEIGHT) - - menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - menuBtn.Text:SetPoint("LEFT", 8, 0) - - -- Subtle separator above group-header rows (hidden on option rows - -- and on a header that is the first visible row) - menuBtn.Sep = menuBtn:CreateTexture(nil, "ARTWORK") - menuBtn.Sep:SetHeight(1) - menuBtn.Sep:SetPoint("TOPLEFT", 4, 1) - menuBtn.Sep:SetPoint("TOPRIGHT", -4, 1) - menuBtn.Sep:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.6) - menuBtn.Sep:Hide() - - menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") - menuBtn.Highlight:SetAllPoints() - - menuBtn:SetScript("OnClick", function(self) - local optKey = self.optKey - -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, optKey) then - UpdateText() - menuFrame:Hide() - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(optKey) end - return - end - - if customSet then - customSet(optKey) - else - dbTable[dbKey] = optKey - end - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, customGet and customGet() or optKey) - end - - UpdateText() - menuFrame:Hide() - DF:UpdateAll() - if callback then callback() end - if parent.RefreshStates then parent:RefreshStates() end - end) - - menuButtons[i] = menuBtn - end - - menuBtn.optKey = opt.key - menuBtn.Text:SetText(opt.value) - menuBtn.Highlight:SetColorTexture(selColor.r, selColor.g, selColor.b, 0.3) - if opt.header then - -- Non-clickable group label (no mouse ⇒ no hover highlight). - -- Heading treatment: small uppercase label in the group colour, - -- separator line above (except when it's the first row), and - -- the option rows beneath it are indented — so headers read as - -- section labels rather than selectable entries. - menuBtn:EnableMouse(false) - local hc = opt.color or C_TEXT_DIM - menuBtn.Text:SetTextColor(hc.r, hc.g, hc.b) - -- SetTextScale (not SetSettingsFont) so the pooled row resets - -- cleanly when it's reused as a regular option row. - menuBtn.Text:SetTextScale(0.85) - if type(opt.value) == "string" then - menuBtn.Text:SetText(opt.value:upper()) - end - menuBtn.Text:ClearAllPoints() - menuBtn.Text:SetPoint("LEFT", 8, 0) - menuBtn.Sep:SetShown(i > 1) - seenHeader = true - else - menuBtn.Text:SetTextScale(1) - menuBtn.Text:ClearAllPoints() - menuBtn.Text:SetPoint("LEFT", seenHeader and 16 or 8, 0) - menuBtn.Sep:Hide() - menuBtn:EnableMouse(true) - if opt.color then - -- per-option colour (e.g. class-coloured spec list) always wins - menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) - elseif currentVal == opt.key then - menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - menuBtn:Show() - menuHeight = menuHeight + ITEM_HEIGHT - end - - -- Width fits the widest item so long options aren't clipped by a narrow - -- opener (refined to max(opener, content) on open, once btn is sized). - menuContentW = 0 - for i = 1, #sortedOptions do - menuContentW = math.max(menuContentW, menuButtons[i].Text:GetStringWidth() or 0) - end - if searchable then - local visible = math.min(#sortedOptions, MAX_VISIBLE) - menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) - menuFrame:SetHeight(visible * ITEM_HEIGHT + SEARCH_HEIGHT + 8) - scrollChild:SetWidth(menuFrame:GetWidth() - 24) - scrollChild:SetHeight(menuHeight + 4) - scrollFrame:SetVerticalScroll(0) - else - menuFrame:SetWidth(menuContentW + 24) - menuFrame:SetHeight(menuHeight + 4) - end - end - - BuildMenuButtons() - - if searchBox then - searchBox:SetScript("OnTextChanged", function(self) - if menuFrame:IsShown() then BuildMenuButtons(self:GetText()) end - end) - end - - -- Allow dynamic dropdowns to swap their option set and regenerate buttons. - container.RebuildOptions = function(_, newOptions) - if newOptions then options = newOptions end - BuildMenuButtons(searchBox and searchBox:GetText() or nil) - UpdateText() - end - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - btn:SetScript("OnClick", function(self) - if menuFrame:IsShown() then - menuFrame:Hide() - S.currentOpenDropdown = nil - else - -- Close any other open dropdown first - CloseOpenDropdown() - -- Dynamic dropdowns regenerate their option list each open. - if optionsFunc then - container:RebuildOptions(optionsFunc()) - elseif searchable then - -- Searchable menus reopen unfiltered (search cleared on hide) - BuildMenuButtons() - else - -- Static menus: refresh selected-value colouring on the pooled rows - local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) - local selColor = accentColor or GetThemeColor() - for i, opt in ipairs(sortedOptions) do - local menuBtn = menuButtons[i] - if menuBtn and not opt.header then - if opt.color then - -- per-option colour (e.g. class-coloured spec list) always wins - menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) - elseif currentVal == opt.key then - menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) - else - menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - end - end - if searchable then - menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) - scrollChild:SetWidth(menuFrame:GetWidth() - 24) - else - menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 24)) - end - menuFrame:Show() - S.currentOpenDropdown = menuFrame - if searchBox then searchBox:SetFocus() end - end - end) - - btn:SetScript("OnShow", UpdateText) - UpdateText() - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so its preview/value (texture swatch, font preview, - -- selected text) greys with the label rather than staying full-bright. - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - UpdateText() -- restore per-option colour on the selected label - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The label - -- sits at the container's TOPLEFT, above the opener, so it is well clear of - -- the menu you are about to click. - GUI:AttachTooltip(container, label, lbl) - - -- SEARCH: Register this setting - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, options, nil, callback) - end - - return container -end - --- ============================================================ --- OUTLINE + SHADOW CONTROLS --- A flag dropdown and a shadow checkbox that both bind to a single stored --- outline value (see DF:OutlineFlag / OutlineHasShadow / ComposeOutline in --- Config.lua). Shadow is decoupled from the outline flag so any flag can be --- combined with a drop shadow, mirroring Grid2's font options. --- ============================================================ - -local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "MONOCHROME, OUTLINE", "MONOCHROME, THICKOUTLINE" } - -function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) - local options = { - NONE = L["None"], - OUTLINE = L["Outline"], - THICKOUTLINE = L["Thick Outline"], - MONOCHROME = L["Monochrome"], - ["MONOCHROME, OUTLINE"] = L["Monochrome Outline"], - ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], - _order = OUTLINE_FLAG_ORDER, - } - local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end - local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end - return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) -end - -function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) - local get = function() return DF:OutlineHasShadow(dbTable[dbKey]) end - local set = function(val) dbTable[dbKey] = DF:ComposeOutline(DF:OutlineFlag(dbTable[dbKey]), val) end - return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) -end - --- ============================================================ --- UNIFIED BORDER CONTROL SET --- Drops the canonical Show / Style / Texture / Size / Colour controls plus --- whichever optional Phase B controls the consumer opts into (offset, inset, --- blendMode, gradient, shadow). Saved-variable keys are built from a single --- camelCase `prefix` (e.g. "defensiveIcon" → "defensiveIconBorderSize"), so --- consumers add one call instead of hand-rolling ~6-15 widgets each. --- --- Each opts.include flag is per-element: "tailor-made to what makes logical --- sense" — the API exposes everything, but consumers opt in only to what fits --- their element. Returns a table of widget references so the caller can add --- per-element extras (dispel-type colour, pulsate, etc.) afterwards. --- --- opts = { --- parent = the panel widget (e.g. self.child) — same first arg the --- underlying CreateCheckbox/Slider/etc. take --- include = { offset=, inset=, blendMode=, gradient=, shadow=, --- classColor=, roleColor=, colorByTime=, colorByType= } --- fullUpdate = callback for full re-render (drop / value-set) --- lightUpdate = callback for slider-drag (size, offsets, shadow sliders) --- lightColors = callback for live colour-picker preview --- refreshStates = optional hook fired when Show/Gradient/Shadow toggles --- change visibility of other widgets --- disableWhen = optional predicate fn(db) → bool. When true, EVERY widget --- (including the Show toggle itself) GREYS OUT in place. This --- is what a consumer whose border sits under a feature toggle --- wants — the addon-wide rule is that a deactivated control --- greys where it is, so the page doesn't reflow and you can --- still see what turning the feature on would give you. --- hideWhen = optional predicate fn(db) → bool. When true, EVERY widget --- (including the Show toggle itself) HIDES. Reserve this for a --- gate that changes WHAT the page offers — a variant switch --- like Pinned Frames' per-set border override, where the --- controls belong to a mode you are not in. A plain on/off --- feature toggle is disableWhen, not this. --- sizeMin / sizeMax / sizeStep = slider range overrides --- offsetMin / offsetMax / offsetStep --- } --- ============================================================ --- CreateAnimationControls — the Border Animation control set --- (Type dropdown + every per-effect tunable), extracted so the base --- Border Animation panel (CreateBorderControls / include.animate) AND --- Aura Designer's Expiring Animation override render an IDENTICAL set of --- widgets from ONE source. Add or remove an effect / tunable here and both --- panels update together — no drift. --- --- group = SettingsGroup the widgets are added to --- dbTable = db / proxy the widgets read & write --- animPrefix = key namespace; widgets target dbTable[animPrefix .. suffix] --- (base border: "BorderAnimation"; AD expiring: --- "ExpiringAnimation") --- opts: --- parent = frame parent for the widgets --- fullUpdate = heavy refresh callback (dropdown / slider-release / colour) --- lightUpdate = light refresh callback (slider-drag) --- lightColors = live colour-picker preview callback (needed for AD's --- proxy, whose sub-table colour writes skip __newindex) --- typeLabel = label for the Type dropdown --- hideExtra = optional predicate; when true the WHOLE block hides --- (the border panel folds the block under Show Border; --- the always-visible Expiring override omits it) --- onTypeChange = runs after the Type dropdown changes (re-layout / reflow) --- perfBanner = show the per-border FPS warning banner (default true) --- Returns the widget table (animationType, animationColor, … ) so the caller --- can merge the handles into its own control table. --- ============================================================ -function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) - opts = opts or {} - local parent = opts.parent - local fullUpdate = opts.fullUpdate or function() end - local lightUpdate = opts.lightUpdate - local lightColors = opts.lightColors - local typeLabel = opts.typeLabel or L["Border Animation"] - local excludeTypes = opts.excludeTypes -- optional set of animation-type keys to omit from the dropdown - local hideExtra = opts.hideExtra - local onTypeChange = opts.onTypeChange or function() end - local showPerfBanner = opts.perfBanner ~= false - - local function aKey(suffix) return animPrefix .. suffix end - local animTypeKey = aKey("Type") - local function animType() return dbTable[animTypeKey] or "NONE" end - local function extraOff() return (hideExtra and hideExtra()) or false end - local function animOff() return extraOff() or animType() == "NONE" end - - -- Sets of effect types each tunable applies to (truthiness on a - -- string-keyed set). Mirrors the per-effect parameter map — keep in - -- sync with StartAnimation's branches in Frames/Border.lua. - -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash - -- thickness, Inset = dash inset. - local hasFrequency = { DF_PULSATE=1, - DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } - local hasParticles = { DF_ORBIT=1, DF_PIXEL=1 } - -- CORNERS_ONLY is hidden from the type dropdown below, but keep its param - -- entries so an indicator that still carries a saved CORNERS_ONLY value shows - -- the right controls. - local hasThickness = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_PIXEL=1 } - -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which - -- modulates the border's own edges and has no separate animRect). - local hasPositioning = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } - -- Scale slider = sparkle size (DF Chase). - local hasScale = { DF_ORBIT=1 } - -- Length slider = bar length (DF Pixel's chasing bars). - local hasLength = { DF_PIXEL=1 } - local cornersOnly = { CORNERS_ONLY=1 } - local function hideUnless(set) - return function() - if animOff() then return true end - return not set[animType()] - end - end - - local w = {} - - -- All DF-owned border effects (no external glow library). The "DF " labels - -- are kept from when they sat alongside the retired LCG glows. - local animTypeOptions = { - NONE = L["None"], - DF_PULSATE = L["DF Pulsate"], - DF_ORBIT = L["DF Chase"], - DF_DASH = L["DF Dash"], - DF_FLASH = L["DF Flash"], - DF_PIXEL = L["DF Pixel"], - DF_PROC = L["DF Proc"], - BLINK = L["Blink"], - -- None first (the "off" option), then alphabetical by label. CORNERS_ONLY - -- is intentionally absent — it's kept in the engine (an existing saved - -- value still renders) but no longer offered as a pickable animation. - _order = { "NONE", "BLINK", "DF_ORBIT", - "DF_DASH", "DF_FLASH", "DF_PIXEL", "DF_PROC", "DF_PULSATE" }, - } - -- Optional caller filter: drop any excluded type from both the value map and - -- the display order (e.g. the Aura Designer border offers only the taint-safe, - -- overlay-recoverable animations — no LCG glows). - if excludeTypes then - for k in pairs(excludeTypes) do animTypeOptions[k] = nil end - local filteredOrder = {} - for _, k in ipairs(animTypeOptions._order) do - if not excludeTypes[k] then filteredOrder[#filteredOrder + 1] = k end - end - animTypeOptions._order = filteredOrder - end - w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, - animTypeOptions, - dbTable, animTypeKey, onTypeChange), 55) - -- Type dropdown respects only the extra gate (e.g. Show Border). With no - -- extra gate (Expiring override) it's always visible. - w.animationType.hideOn = hideExtra or function() return false end - - -- Perf warning: animations run an OnUpdate (or LCG internal animation) - -- per active border, which adds up in 20-30 player raids. - if showPerfBanner then - -- staticHeight ONLY where the host reflows widget WIDTHS on every layout - -- pass — i.e. the Aura Designer indicator card (its parent carries - -- dfAD_ReflowWidgets). There a self-sizing banner feeds a SetHeight -> - -- OnSizeChanged -> relayout -> SetWidth loop that drops FPS, so we predict - -- a fixed height instead. On normal settings pages the host lays out once, - -- so the banner MUST self-size to its wrapped text: a fixed height - -- overflows (text spills past the box) on narrow windows until a manual - -- drag forces a relayout. - local reflowingHost = parent and parent.dfAD_ReflowWidgets ~= nil - local perfBanner = GUI:CreateInfoBanner(parent, { - tone = "caution", - text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], - staticHeight = reflowingHost or nil, - minHeight = 56, - }) - w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) - w.animationPerfBanner.hideOn = animOff - end - - -- Animation colour applies to every effect except DF_PULSATE (which - -- modulates the border's own edge alpha — no separate colour). lightColors - -- is threaded through so AD's proxy gets live preview while dragging. - w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Color"], - dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) - w.animationColor.hideOn = function() - return animOff() or animType() == "DF_PULSATE" - end - - -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. - -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), - -- and the OnUpdate effects fall back to a sensible default period at 0. - w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], - 0, 4, 0.05, dbTable, aKey("Frequency"), - fullUpdate, lightUpdate, true), 55) - w.animationFrequency.hideOn = hideUnless(hasFrequency) - -- ⚠ This slider genuinely means different things per effect (see the comment - -- above), which is exactly why it needs saying out loud — nobody discovers - -- "0 = hold still" by dragging. - w.animationFrequency.tooltip = L["How fast the effect runs. On DF Dash this is how quickly the dashes march around the edge, and 0 holds them still. On the others it is the pulse rate, where 0 means the effect's own default speed."] - - w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], - 1, 16, 1, dbTable, aKey("Particles"), - fullUpdate, lightUpdate, true), 55) - w.animationParticles.hideOn = hideUnless(hasParticles) - w.animationParticles.tooltip = L["How many separate lights travel around the border. More reads as busier and costs a little more to draw."] - - w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], - 1, 30, 1, dbTable, aKey("Length"), - fullUpdate, lightUpdate, true), 55) - w.animationLength.hideOn = hideUnless(hasLength) - w.animationLength.tooltip = L["How long each moving segment is. Short values read as darting sparks, long ones as a sweeping tail."] - - w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], - 1, 12, 1, dbTable, aKey("Thickness"), - fullUpdate, lightUpdate, true), 55) - w.animationThickness.hideOn = hideUnless(hasThickness) - w.animationThickness.tooltip = L["How heavy the moving effect is. Separate from Border Thickness — the animation draws on its own layer, so it can be thicker or thinner than the border underneath."] - - w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], - 0.5, 3, 0.05, dbTable, aKey("Scale"), - fullUpdate, lightUpdate, true), 55) - w.animationScale.hideOn = hideUnless(hasScale) - - w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], - -50, 50, 1, dbTable, aKey("Inset"), - fullUpdate, lightUpdate, true), 55) - w.animationInset.hideOn = hideUnless(hasPositioning) - w.animationInset.tooltip = L["Moves the effect in or out from the edge, independently of the border. Push it outward to make a glow spill past the frame."] - - w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], - -50, 50, 1, dbTable, aKey("OffsetX"), - fullUpdate, lightUpdate, true), 55) - w.animationOffsetX.hideOn = hideUnless(hasPositioning) - - w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], - -50, 50, 1, dbTable, aKey("OffsetY"), - fullUpdate, lightUpdate, true), 55) - w.animationOffsetY.hideOn = hideUnless(hasPositioning) - - -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). - w.animationHideIntro = group:AddWidget(GUI:CreateCheckbox(parent, L["Hide Intro Flash"], - dbTable, aKey("ProcStart"), fullUpdate), 30) - w.animationHideIntro.hideOn = hideUnless({ DF_FLASH = 1, DF_PROC = 1 }) - w.animationHideIntro.tooltip = L["These effects open with a one-off burst before settling into their loop. Turn this on to skip the burst and go straight to the loop."] - - w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], - 2, 40, 1, dbTable, aKey("CornerLength"), - fullUpdate, lightUpdate, true), 55) - w.animationCornerLength.hideOn = hideUnless(cornersOnly) - w.animationCornerLength.tooltip = L["How far the effect runs along each edge from the corner before stopping. Small values leave four short brackets instead of a full outline."] - - return w -end - --- ============================================================ -function GUI:CreateBorderControls(group, dbTable, prefix, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local fullUpdate = opts.fullUpdate or function() end - local lightUpdate = opts.lightUpdate - local lightColors = opts.lightColors - local refreshStates = opts.refreshStates - local hideWhen = opts.hideWhen - local disableWhen = opts.disableWhen - - local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 - local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 - - local function key(suffix) return prefix .. suffix end - local showKey = key("ShowBorder") - -- The Show toggle only respects the parent-level hideWhen. Everything - -- else respects hideWhen OR the Show toggle being off. - -- - -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is - -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` - -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, - -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. - -- For consumers with a different dbTable — notably Aura Designer's - -- per-aura proxy — this is the only way the visibility predicates see - -- the actual border state (e.g. proxy.BorderStyle, not the unrelated - -- DF.db.party.BorderStyle which doesn't exist). - local function hideShow() return hideWhen and hideWhen(dbTable) or false end - -- Show Border OFF no longer HIDES the border controls — they stay visible and - -- GREY OUT (disableOn = borderOff, applied by the loop at the end of this - -- function) so the panel previews them. `hideOff` now means "hidden by the - -- parent/variant gate only" (whatever the consumer passes via hideWhen); the - -- name is kept so the existing `.hideOn = hideOff` references read unchanged. - local function hideOff() return hideShow() end - local function borderOff() return dbTable[showKey] == false end - - local w = {} - - -- opts.noShowToggle: suppress the built-in "Show Border" checkbox for - -- consumers that gate the whole border on an external toggle (e.g. the - -- Targeted Spells "Highlight Important Spells" master). With the checkbox - -- gone, showKey stays nil so hideOff() reduces to hideShow() — the toolkit - -- shows/hides purely on the external hideWhen. - if not opts.noShowToggle then - w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 30) - w.show.hideOn = hideShow - end - - -- Slider label reads "Border Thickness" (more meaningful than "Size") but - -- the underlying db key stays `BorderSize` and spec.size in the - -- backend stays the same — purely a user-facing rename, no migration. - w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, - dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) - w.size.hideOn = hideOff - - -- Gradient is a STYLE, not a separate toggle. When the consumer opts into - -- gradient via include.gradient, we expose GRADIENT as a third dropdown - -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. - local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], - _order = { "SOLID", "TEXTURE" } } - if include.gradient then - styleOptions.GRADIENT = L["Gradient"] - -- Insert GRADIENT between SOLID and TEXTURE so the order reads - -- "simple colour → two colours → custom texture" in the dropdown. - styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } - end - w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], - styleOptions, dbTable, key("BorderStyle"), function() - -- Match the frame border: pick the first LSM border when switching - -- to Texture without one configured. - if dbTable[key("BorderStyle")] == "TEXTURE" then - local list = DF.GetBorderList and DF:GetBorderList() or nil - local t = dbTable[key("BorderTexture")] - if list and (not t or t == "" or t == "SOLID") then - dbTable[key("BorderTexture")] = next(list) - end - end - if refreshStates then refreshStates() end - fullUpdate() - end), 55) - w.style.hideOn = hideOff - - -- isGradient is declared up here so the Style-dependent widget cluster - -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) - -- can sit immediately below the Style dropdown — the consequence of the - -- user's style choice reads top-to-bottom without scrolling past - -- unrelated inset / offset / blend controls first. - local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end - - w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], - DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) - w.texture.hideOn = function() - return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" - end - - -- Gradient pickers — only visible under Style = GRADIENT. Grouped here - -- (between Texture and the Colour Source dropdown) so all style-dependent - -- widgets sit directly under the Style dropdown that controls them. - -- The standalone "Border Gradient" checkbox was removed when Style - -- absorbed it; Style is now the single source of truth so it's not - -- possible to pick "Solid + Class Color" then have a Gradient checkbox - -- stomp the class colour (the previous UX bug). Legacy - -- `BorderGradientEnabled = true` profiles are migrated to - -- `BorderStyle = "GRADIENT"` on db load. - if include.gradient then - local function gradHide() return hideOff() or not isGradient() end - - w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Color"], - dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) - w.gradientStart.hideOn = gradHide - w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Color"], - dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) - w.gradientEnd.hideOn = gradHide - w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], - { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, - dbTable, key("BorderGradientDirection"), fullUpdate), 55) - w.gradientDirection.hideOn = gradHide - end - - -- Colour Source dropdown sits ABOVE the colour picker so the relationship - -- "source → resulting colour" reads top-to-bottom in the panel. The - -- options table is built dynamically: Static is always present; Class - -- and Role are added if the consumer opted in via the matching include. - -- Hidden in GRADIENT style — gradient owns its own colours, no resolver - -- chain applies (see Border:BuildSpec). - local sourceKey = key("BorderColorSource") - local hasSourceDropdown = include.classColor or include.roleColor - if hasSourceDropdown then - local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } - if include.classColor then - sourceOptions.CLASS = L["Class"] - sourceOptions._order[#sourceOptions._order + 1] = "CLASS" - end - if include.roleColor then - sourceOptions.ROLE = L["Role"] - sourceOptions._order[#sourceOptions._order + 1] = "ROLE" - end - -- Default the source from the legacy boolean keys when first opened. - if dbTable[sourceKey] == nil then - if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" - elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" - else dbTable[sourceKey] = "STATIC" end - end - w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], - sourceOptions, dbTable, sourceKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 55) - w.colorSource.hideOn = function() return hideOff() or isGradient() end - w.colorSource.tooltip = L["Where the border colour comes from. Static uses the colour below; Class and Role read it from the unit, so the border tells you who you are looking at without reading the name."] - end - - -- Static colour picker — only visible when source is STATIC (or when the - -- consumer didn't enable any resolver at all, so source doesn't exist). - -- Hidden in GRADIENT style (gradient uses its own start/end pickers). - w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), - true, fullUpdate, lightColors, lightColors ~= nil), 35) - w.color.hideOn = function() - if hideOff() or isGradient() then return true end - if hasSourceDropdown then - local src = dbTable[sourceKey] or "STATIC" - return src ~= "STATIC" - end - return false - end - - -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes - -- the SAME alpha component the colour picker exposes - -- (BorderColor.a), so the slider is just a convenient handle for - -- the picker's alpha bar — no separate alpha key to migrate or keep in - -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the - -- two gradient pickers each carry their own alpha, and a single slider - -- has no obvious meaning). - if include.alpha then - -- Ensure the underlying colour table has an alpha component so the - -- slider doesn't read nil on first open. The picker also seeds .a but - -- we don't depend on widget-creation order. - local c = dbTable[key("BorderColor")] - if type(c) ~= "table" then - c = { r = 0, g = 0, b = 0, a = 1 } - dbTable[key("BorderColor")] = c - end - if c.a == nil then c.a = 1 end - - -- Read-time nil-guard: these closures fire on the slider's OnShow at - -- arbitrary later times (tab/page re-show, mode switch), NOT just at - -- creation. The seed above only guarantees the table exists NOW — a proxy - -- dbTable can resolve BorderColor to nil later (e.g. re-showing the AD page - -- for a mode whose config doesn't surface the key), so re-read and guard - -- each call instead of assuming the table is still there. - w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, - nil, nil, fullUpdate, lightColors or lightUpdate, true, - function() - local bc = dbTable[key("BorderColor")] - return (bc and bc.a) or 1 - end, - function(v) - local bc = dbTable[key("BorderColor")] - if bc then bc.a = v end - end), 55) - w.alpha.hideOn = function() return hideOff() or isGradient() end - end - - if include.inset then - w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, - dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) - w.inset.hideOn = hideOff - -- Thickness / Inset / Offset are three similar-sounding sliders that do - -- different things; the tooltip lives here because Inset is the one - -- nobody guesses. - w.inset.tooltip = L["Pulls the border inward (positive) or pushes it outward (negative) from the edge. Thickness is how heavy the line is, Inset is how far in it sits, Offset slides the whole border sideways."] - end - - if include.offset then - w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, - dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) - w.offsetX.hideOn = hideOff - w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, - dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) - w.offsetY.hideOn = hideOff - -- No tooltip on Offset X/Y, deliberately, and the same goes for every - -- other Offset slider in the addon (~60 of them): an offset is a well - -- understood control and a tooltip restating it is noise. Inset is the - -- one that needs explaining, so the Thickness / Inset / Offset - -- distinction is spelled out THERE, once. Krathe's call, 2026-07-27 — - -- these two briefly had tooltips and Border Shadow's offsets did not, - -- which is the inconsistency that prompted it. - end - - if include.blendMode then - w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], - { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"], DISABLE = L["Disable"] }, - dbTable, key("BorderBlendMode"), fullUpdate), 55) - w.blendMode.hideOn = hideOff - w.blendMode.tooltip = L["How the border colour mixes with whatever is behind it. Blend is normal. Add brightens and is what makes a colour glow. Modulate darkens. Disable ignores opacity entirely and draws the colour flat."] - end - - if include.shadow then - local shadowOnKey = key("BorderShadowEnabled") - w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 30) - w.shadowEnabled.hideOn = hideOff - -- Border Shadow OFF greys (not hides) its sub-controls — a nested boolean - -- toggle, same grey-everything rule. The end-of-function loop OR-composes - -- borderOff, so these also grey when Show Border is off. - local function shadowOff() return dbTable[shadowOnKey] == false end - - w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Color"], - dbTable, key("BorderShadowColor"), true, fullUpdate), 35) - w.shadowColor.hideOn = hideOff - w.shadowColor.disableOn = shadowOff - w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, - dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) - w.shadowSize.hideOn = hideOff - w.shadowSize.disableOn = shadowOff - w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, - dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) - w.shadowOffsetX.hideOn = hideOff - w.shadowOffsetX.disableOn = shadowOff - w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, - dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) - w.shadowOffsetY.hideOn = hideOff - w.shadowOffsetY.disableOn = shadowOff - end - - -- ===== Animation (Stage 3) ===== - -- include.animate drops the full Border Animation control set (Type - -- dropdown + per-effect tunables, each with a hideOn keyed to the effect - -- it applies to). Built from the shared GUI:CreateAnimationControls so the - -- base panel and AD's Expiring override never drift. The whole block folds - -- under Show Border via hideExtra = hideOff. Widget handles are merged back - -- onto `w` so existing references (w.animationType, …) are preserved. - if include.animate then - local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { - parent = parent, - fullUpdate = fullUpdate, - lightUpdate = lightUpdate, - lightColors = lightColors, - typeLabel = L["Border Animation"], - -- Optional caller filter, forwarded from the CreateBorderControls call - -- site (e.g. the Aura Designer border restricts to overlay-recoverable - -- animation types). nil for every other caller → full type list. - excludeTypes = opts.animExcludeTypes, - hideExtra = hideOff, - onTypeChange = function() - if refreshStates then refreshStates() end - fullUpdate() - end, - }) - for k, v in pairs(aw) do w[k] = v end - end - - -- ===== Colour resolver toggles (Stage 2) ===== - -- These flip BorderColor's source from the static picker to a per-unit / - -- per-aura / per-tick computation. BuildSpec applies them in priority - -- order (type > time > class > role > static) when the consumer passes - -- ctx to BuildSpec. The static colour picker still controls the fallback - -- (when ctx is missing or the resolver yields nil). - - -- (Colour Source dropdown + Static colour picker + Alpha slider are wired - -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the - -- relationship "source → colour" reads top-to-bottom in the panel.) - - if include.colorByTime then - w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) - w.colorByTime.hideOn = hideOff - -- The actual colour curve picker is consumer-specific (e.g. AD's - -- existing expiring colour curve) and is added by the consumer - -- alongside this checkbox. - end - - if include.colorByType then - w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) - w.colorByType.hideOn = hideOff - end - - -- Two independent greys, both composed on top of whatever disableOn a control - -- already carries (e.g. the shadow sub-controls), and both leaving the - -- variant hideOn untouched: - -- disableWhen — the CONSUMER's gate: the feature this border belongs to is - -- switched off. Applies to EVERY widget including the Show Border - -- checkbox, since with the feature off there is nothing for it to show. - -- borderOff — Show Border itself is off. Applies to everything EXCEPT the - -- Show Border checkbox, which has to stay clickable to turn it back on. - -- RefreshChildStates applies disableOn to group children, and CreateCheckbox - -- auto-refreshes on toggle, so both greys update live. - for k, widget in pairs(w) do - if type(widget) == "table" and widget.SetEnabled then - local prev = widget.disableOn - local isShow = (k == "show") - widget.disableOn = function(d) - if disableWhen and disableWhen(dbTable) then return true end - if not isShow and borderOff() then return true end - return (prev and prev(d)) or false - end - end - end - - return w -end - --- ============================================================ --- SHARED TEXT-STYLE CONTROLS (pairs with DF.TextStyle — the engine consumers --- style FontStrings through). Mirrors CreateBorderControls: one builder, every --- text block in the addon renders the same control flow instead of a hand-rolled --- copy per page. Emits, in the pages' established order: --- Font, Scale, Outline, Shadow, [Color], Anchor, Offset X/Y, [Justify H, Justify V] --- Key convention: Font/Scale/Outline/Anchor/X/Y/JustifyH/JustifyV/Color. --- --- opts: --- parent REQUIRED — the page scroll child (self.child) --- include = { color = false, justify = true, anchor = true, offsets = true } --- colorLabel colour picker label (default L["Text Color"]) --- disableOn predicate applied to EVERY created widget (page-level gate) --- hideOn predicate applied to EVERY created widget --- colorDisableOn EXTRA disable gate for the colour picker only (e.g. colour-by-time on) --- onChange full-update callback (dropdowns / colour commit) --- onDrag lightweight slider-drag callback (also colour live-preview) --- scaleMin/Max/Step, offsetMin/Max — slider ranges (defaults 0.5–2.0 ×0.05, ±150) --- Returns the created widgets keyed { font, scale, outline, shadow, color, anchor, --- offsetX, offsetY, justifyH, justifyV } so pages can attach extra gates. --- ============================================================ -function GUI:CreateTextControls(group, dbTable, prefix, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local onChange = opts.onChange - local onDrag = opts.onDrag or onChange - local L = DF.L - - local scaleMin, scaleMax, scaleStep = opts.scaleMin or 0.5, opts.scaleMax or 2.0, opts.scaleStep or 0.05 - local offMin, offMax = opts.offsetMin or -150, opts.offsetMax or 150 - - local function key(suffix) return prefix .. suffix end - local widgets = {} - - -- Apply the shared page gates to a widget, composing with any the widget factory set. - local function gate(w) - if opts.disableOn then - local prev = w.disableOn - w.disableOn = function(d) return (opts.disableOn(d) or (prev and prev(d))) and true or false end - end - if opts.hideOn then w.hideOn = opts.hideOn end - return w - end - - widgets.font = gate(group:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], dbTable, key("Font"), onChange), 55)) - widgets.scale = gate(group:AddWidget(GUI:CreateSlider(parent, L["Scale"], scaleMin, scaleMax, scaleStep, dbTable, key("Scale"), nil, onDrag, true), 55)) - widgets.outline = gate(group:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], dbTable, key("Outline"), onChange), 55)) - widgets.shadow = gate(group:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], dbTable, key("Outline"), onChange), 30)) - - if include.color then - widgets.color = gate(group:AddWidget(GUI:CreateColorPicker(parent, opts.colorLabel or L["Text Color"], dbTable, key("Color"), false, onChange, onDrag, true), 35)) - if opts.colorDisableOn then - local prev = widgets.color.disableOn - widgets.color.disableOn = function(d) return (opts.colorDisableOn(d) or (prev and prev(d))) and true or false end - end - end - - if include.anchor ~= false then - local anchorOptions = { - CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], - TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], - } - widgets.anchor = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], anchorOptions, dbTable, key("Anchor"), onChange), 55)) - -- Anchor vs Justify is the pair people get wrong: one places the text, - -- the other arranges it within its own box. Both say so, from their side. - widgets.anchor.tooltip = L["Which part of the element the text is pinned to. Offset X and Y then nudge it from there."] - end - - if include.offsets ~= false then - widgets.offsetX = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], offMin, offMax, 1, dbTable, key("X"), nil, onDrag, true), 55)) - widgets.offsetY = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], offMin, offMax, 1, dbTable, key("Y"), nil, onDrag, true), 55)) - end - - -- Justify is OPT-IN (include.justify = true). It's redundant with Anchor for short - -- single-token text on a small icon (duration/stacks) and boxing to justify TRUNCATES - -- wide text like "59m" — so the aura pages don't expose it. The DF.TextStyle engine - -- still honors JustifyH/JustifyV keys for a future wide/fixed-region consumer. - if include.justify then - local justifyHOptions = { [""] = L["Default"], LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } - local justifyVOptions = { [""] = L["Default"], TOP = L["Top"], MIDDLE = L["Middle"], BOTTOM = L["Bottom"] } - widgets.justifyH = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify H"], justifyHOptions, dbTable, key("JustifyH"), onChange), 55)) - widgets.justifyH.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] - widgets.justifyV = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify V"], justifyVOptions, dbTable, key("JustifyV"), onChange), 55)) - widgets.justifyV.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] - end - - return widgets -end + +-- Counterpart to ShowTooltip: hide the shared GameTooltip. Wrapped so callers +-- route through GUI instead of poking GameTooltip directly. +function GUI:HideTooltip() + GameTooltip:Hide() +end + +-- ============================================================ +-- ATTACHING a tooltip to a settings widget — ONE way, for every factory. +-- +-- Three factories used to each have their own idea, and on one page the same +-- gesture did three different things: +-- checkbox .tooltip hover the container ANCHOR_CURSOR +-- dropdown .tooltip hover the BUTTON ANCHOR_CURSOR +-- slider .tooltipText hover the SLIDER ANCHOR_RIGHT +-- Worse, on a dropdown and a slider the LABEL sits above the control, outside +-- its hit rect, so hovering the words never did anything — while on a checkbox +-- (label beside the box, inside the container) it did. Five more factories — +-- colour picker, font / texture dropdown, input, growth control — had no +-- tooltip support at all, so ~136 controls could not carry one. +-- +-- The rule now: THE HIT AREA IS THE LABEL, and only the label. +-- +-- ⚠ This is deliberately NOT the whole widget. The first version of this hovered +-- the control too, which is the obvious reading of "make the label work" — but a +-- cursor-anchored tooltip then sits on top of the slider or dropdown you are +-- trying to read and operate, and no amount of offsetting it fully solves that, +-- because the thing you point at IS the thing being covered. Krathe's call, +-- 2026-07-27, after trying both. Reading and adjusting are separate gestures: +-- point at the words to find out what it does, point at the control to use it. +-- +-- The label is a FontString and cannot take mouse input, so each widget gets one +-- invisible frame sized to the label's own rect. That also handles a label wider +-- than its container for free (the checkbox case) — the frame follows the TEXT, +-- not the box, so there is no hit-rect arithmetic to keep in sync. +-- +-- The anchor is whatever ShowTooltip defaults to — set in ONE place so no page +-- can drift. Nothing here passes an anchor, deliberately. +-- +-- The spec is read AT HOVER TIME, not when it is attached — every call site +-- sets it after creation, on the container the factory returned: +-- widget.tooltip = "body" title = the widget's own label +-- widget.tooltip = { title=, lines=, tone= } the full ShowTooltip shape +-- widget.tooltipText / .tooltipSubText legacy pair, still honoured +-- ============================================================ +local function ResolveTooltipSpec(widget, label) + local t = widget.tooltip + if type(t) == "table" then + -- Full spec from the caller. Default the title to the label so the + -- common case only has to say what the setting DOES. + if t.title == nil then t.title = label end + return t + end + if type(t) == "string" and t ~= "" then + return { title = label, lines = { t } } + end + -- Legacy pair. Deliberately NOT re-titled from the label: these read as + -- title-then-subtitle by design (the Frame Level explainer, the override + -- markers), and re-titling them would change tooltips that are already + -- correct. New code should use .tooltip. + if widget.tooltipText then + return { + title = widget.tooltipText, + lines = widget.tooltipSubText and { widget.tooltipSubText } or nil, + } + end + return nil +end + +-- widget the frame the caller holds and sets .tooltip on (the container) +-- label the default title +-- labelRegion the label FontString — the hit frame is built over ITS rect +function GUI:AttachTooltip(widget, label, labelRegion) + if not labelRegion then return end + + local hit = CreateFrame("Frame", nil, widget) + -- Two-corner anchored to the FontString, so it tracks the text if the label + -- is ever re-set or re-fonted. The 2px vertical bleed makes a single line of + -- small text comfortable to hit without reaching the control below it. + hit:SetPoint("TOPLEFT", labelRegion, "TOPLEFT", 0, 2) + hit:SetPoint("BOTTOMRIGHT", labelRegion, "BOTTOMRIGHT", 0, -2) + hit:EnableMouse(true) + -- Above the widget's own children so the label area wins the mouse, but it + -- only ever covers the TEXT, so nothing clickable is behind it. + hit:SetFrameLevel((widget:GetFrameLevel() or 0) + 5) + + hit:SetScript("OnEnter", function() + local spec = ResolveTooltipSpec(widget, label) + if spec then GUI:ShowTooltip(hit, spec) end + end) + hit:SetScript("OnLeave", function() GUI:HideTooltip() end) + + widget.dfTooltipHit = hit -- exposed for a caller that needs to re-anchor it + return hit +end + +function GUI:StyleButton(btn, opts) + opts = opts or {} + if opts.width or opts.height then + btn:SetSize(opts.width or btn:GetWidth(), opts.height or btn:GetHeight()) + end + -- Land the height on an EVEN number of device pixels, whether it came from + -- opts or the caller sized the button itself. Buttons are chained with + -- centre-aligning anchors (Copy <- Sync <- Reset, Clicks <- Test <- Unlock), + -- so an odd height puts the whole row on a half pixel -- and since nothing + -- corrects a control's position at runtime, getting the size right at + -- construction is the only thing that keeps their edges crisp. + -- Construction-time and once, so it cannot drive an OnSizeChanged cascade. + local bh = btn:GetHeight() + if bh and bh > 0 then + local sh = SnapHeightEven(btn, bh) + if sh and math.abs(sh - bh) > 1e-4 then btn:SetHeight(sh) end + end + CreateElementBackdrop(btn) -- mixes in BackdropTemplate if needed + + -- Optional label + leading icon. opts.icon = { texture, size (14), + -- color {r,g,b}, gap (4) }. opts.align controls layout: + -- "center" (default) — centre the icon+label as a GROUP (text-only centres + -- the label; icon-only centres the icon). Best for compact buttons whose + -- width ~ their content. + -- "left" — pin the icon at opts.leftPad (12) with the label after it. Best + -- for wide / full-width list-style buttons, where centred content floats + -- in a sea of empty space. + local iconOpt = opts.icon + local iconGap = (iconOpt and iconOpt.gap) or 4 + local iconW = (iconOpt and (iconOpt.size or 18)) or 0 + local hasText = opts.text ~= nil and opts.text ~= "" + local align = opts.align or "center" + local leftPad = opts.leftPad or 12 + -- Toned buttons (danger / success): neutral at rest with an accent-coloured + -- label+icon — soft red for destructive, soft green for affirmative — plus the + -- accent hover wash. A coloured-text button, NOT a filled CTA (the accent set + -- below drives the hover). Mirrors each other so Delete/Save read as a pair. + local toneLabel = (opts.tone == "danger" and { 0.9, 0.45, 0.45 }) + or (opts.tone == "success" and { 0.4, 0.85, 0.5 }) or nil + + if opts.text ~= nil then + if not btn.Text then + btn.Text = btn:CreateFontString(nil, "OVERLAY", opts.font or "DFFontHighlightSmall") + -- Register it as the button's font string so the NATIVE Button:SetText + -- / GetText keep working. Without this, a caller that relabels later + -- (a Start/Stop or Pause/Resume toggle) silently no-ops and the button + -- freezes on its first label -- an easy regression when converting a + -- Blizzard-template button, which always had one. + if btn.SetFontString then btn:SetFontString(btn.Text) end + end + btn.Text:SetText(opts.text) + if toneLabel then + btn.Text:SetTextColor(toneLabel[1], toneLabel[2], toneLabel[3]) + else + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + + if iconOpt then + btn.Icon = btn.Icon or btn:CreateTexture(nil, "OVERLAY") + btn.Icon:SetTexture(iconOpt.texture) + btn.Icon:SetSize(iconW, iconW) + if iconOpt.color then + btn.Icon:SetVertexColor(iconOpt.color.r, iconOpt.color.g, iconOpt.color.b) + elseif toneLabel then + btn.Icon:SetVertexColor(toneLabel[1], toneLabel[2], toneLabel[3]) + end + end + + -- Anchor the icon/label per alignment. + if align == "left" then + if iconOpt then + btn.Icon:ClearAllPoints() + btn.Icon:SetPoint("LEFT", leftPad, 0) + end + if btn.Text then + btn.Text:ClearAllPoints() + if iconOpt then + btn.Text:SetPoint("LEFT", btn.Icon, "RIGHT", iconGap, 0) + else + btn.Text:SetPoint("LEFT", leftPad, 0) + end + end + else + if btn.Text then + btn.Text:ClearAllPoints() + -- Offset right by half the icon+gap so the icon+label GROUP centres. + btn.Text:SetPoint("CENTER", btn, "CENTER", (iconOpt and hasText) and (iconW + iconGap) / 2 or 0, 0) + end + if iconOpt then + btn.Icon:ClearAllPoints() + if hasText then + btn.Icon:SetPoint("RIGHT", btn.Text, "LEFT", -iconGap, 0) + else + btn.Icon:SetPoint("CENTER", btn, "CENTER", 0, 0) + end + end + end + + -- Hover: an accent wash via the native HIGHLIGHT layer (auto-shown on + -- mouseover, like StyleCheckButton / the menu buttons) PLUS a darker accent + -- border for definition. `primary` buttons additionally keep a persistent + -- accent-tinted fill + accent border at rest. Accent = explicit opts.accent + -- (e.g. ClickCasting green) or the mode accent (party purple / raid orange). + local accent = opts.accent + -- tone presets: a destructive "danger" button reuses ALL the accent + -- machinery (hover wash, hover border, primary fill) with a fixed FF4444 red. + -- So a plain danger button is neutral-at-rest with a red hover, and + -- danger+primary is a filled red CTA. Fixed colour ⇒ it won't theme-track + -- (correct — destructive red shouldn't follow the party/raid accent). + if not accent then + if opts.tone == "danger" then + accent = { r = 1, g = 0.27, b = 0.27 } + elseif opts.tone == "success" then + accent = { r = 0.3, g = 0.8, b = 0.45 } + end + end + local primary = opts.primary + local fadeActiveText = opts.fadeActiveText + -- Underline TAB style (opts.tab): the button is transparent (no fill/border) + -- and its active cue is a 2px accent stripe along the bottom + an accent label + -- (dim label when inactive). Driven by SetActive, like a toggle. Distinct from + -- the legacy `isTab` filled-sidebar branch in restBackdrop. + local isTabStyle = opts.tab + -- Ghost action (opts.ghost): transparent like a tab but with no underline — an + -- accent label + faint hover wash. For quiet inline actions (e.g. "+ Add"). + local ghost = opts.ghost + -- Persistent semantic accent (opts.tinted): the accent is meaningful and stays + -- ON at rest — faint accent fill + accent border + accent label — rather than + -- being a neutral button with an accent hover. For role quick-add buttons etc. + -- where the colour IS the button's identity. Pass a fixed opts.accent. + local tinted = opts.tinted + -- Neutral hover (opts.hoverTone = "neutral"): the wash is the plain C_HOVER + -- grey and the border does NOT go accent. For surfaces that are a PLACE + -- rather than an action -- a card header, a list row -- where an accent + -- hover would read as "this is a call to action". Card headers and dropdown + -- rows previously hand-rolled this as an OnEnter/OnLeave SetBackdropColor + -- swap, which duplicated the rest colours at every site. + local neutralHover = opts.hoverTone == "neutral" + -- opts.restBorderColor: let a consumer keep its OWN border identity at rest -- + -- e.g. an Aura/Text group card header tinted by that group's colour -- while + -- fill, hover, selection and disabled all stay shared. Applies ONLY to the + -- neutral rest branch; active / primary / tinted keep their accent-derived + -- borders, so the override can't fight a state the button is in. + local restBorder = opts.restBorderColor + local hl = btn:GetHighlightTexture() or btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture("Interface\\Buttons\\WHITE8x8") + hl:SetAllPoints(btn) + btn.Highlight = hl + + if isTabStyle then + local stripe = btn:CreateTexture(nil, "OVERLAY") + stripe:SetTexture("Interface\\Buttons\\WHITE8x8") + stripe:SetHeight(3) + stripe:SetPoint("BOTTOMLEFT", 0, 0) -- full-width underline (no insets) + stripe:SetPoint("BOTTOMRIGHT", 0, 0) + stripe:Hide() + btn.dfTabStripe = stripe + end + + -- The resting backdrop the button returns to on mouse-out (and that primary + -- buttons also wear permanently): accent-tinted for primary, the active-tab + -- panel colour for active tabs, otherwise the neutral element colour. + local function restBackdrop(self, a) + if isTabStyle then + -- Underline tab: a faint neutral cell when inactive (so every tab's + -- bounds stay visible and the active one doesn't appear to "grow"), + -- and a stronger accent fill when active (a held-hover highlight) + -- beneath its stripe. + if self.dfActive then + self:SetBackdropColor(a.r, a.g, a.b, 0.18) + else + self:SetBackdropColor(1, 1, 1, 0.05) + end + self:SetBackdropBorderColor(0, 0, 0, 0) + return + end + if ghost then + -- Ghost action: a faint neutral cell (matching inactive tabs) with an + -- accent label, so it sits consistently in a tab strip; the wash + -- brightens it on hover. + self:SetBackdropColor(1, 1, 1, 0.05) + self:SetBackdropBorderColor(0, 0, 0, 0) + return + end + if tinted then + -- Persistent semantic accent: faint accent fill + medium accent border + -- at rest (label/icon accent-coloured in ApplyThemeColor). Hover adds a + -- full-accent border + the wash brightens the fill. + self:SetBackdropColor(a.r * 0.15, a.g * 0.15, a.b * 0.15, 0.9) + self:SetBackdropBorderColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 0.8) + return + end + if self.dfActive then + -- Selected toggle/segmented button: a subtle accent fill + a clear + -- accent border (more than the muted hover border, but toned down from + -- full so it doesn't read as a heavy bright outline). + self:SetBackdropColor(a.r * 0.3, a.g * 0.3, a.b * 0.3, 1) + self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) + elseif primary then + -- Filled accent CTA: a medium accent fill with a slightly darker + -- accent border (the same border-darker-than-fill relationship as the + -- standard hover) so it reads like an emphasised standard button, not + -- a dark fill ringed by a harsh bright outline. + self:SetBackdropColor(a.r * 0.5, a.g * 0.5, a.b * 0.5, 1) + self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) + elseif self.isTab and self.isActive then + self:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 1) + self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + else + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + if restBorder then + self:SetBackdropBorderColor(restBorder.r or restBorder[1], + restBorder.g or restBorder[2], + restBorder.b or restBorder[3], + restBorder.a or restBorder[4] or 1) + else + self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + end + end + end + + -- The hover wash's colour, factored out so it can be re-resolved at HOVER time + -- rather than only at build time. The theme listener registered below lands on + -- the button's PARENT, and a button parented into a scroll child -- every row + -- in the Filter Designer, the spell list, the binding editor -- hangs it on a + -- frame no theme walk ever visits. Its wash then stays frozen at whatever the + -- accent was when the page was built, so a raid-mode hover drew an orange + -- border (computed live in OnEnter) over a party-blue fill. The border was + -- always right; the wash simply wasn't asking again. + local function applyWash(c) + if neutralHover then + hl:SetVertexColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.55) + else + hl:SetVertexColor(c.r, c.g, c.b, (isTabStyle or ghost) and 0.15 or 0.30) + end + end + + btn.ApplyThemeColor = function(c) + applyWash(c) + if isTabStyle then + restBackdrop(btn, c) -- keep the tab transparent (no fill/border) + -- refresh the stripe colour + the active label to the new accent + if btn.dfTabStripe then btn.dfTabStripe:SetColorTexture(c.r, c.g, c.b, 1) end + if btn.dfActive and btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end + elseif ghost or tinted then + restBackdrop(btn, c) -- faint cell / tinted fill; accent-coloured label + if btn.Text then btn.Text:SetTextColor(c.r, c.g, c.b) end + if btn.Icon then btn.Icon:SetVertexColor(c.r, c.g, c.b) end -- icon matches the accent label + elseif primary or btn.dfActive then + restBackdrop(btn, c) -- refresh persistent accent + end + end + + -- Toggle/segmented selection: btn:SetActive(true) marks the button as the + -- current selection (prominent accent border via restBackdrop); false returns + -- it to its normal rest. The owning group is responsible for clearing the + -- previously-active button. Works on any StyleButton'd button. + btn.SetActive = function(self, active) + self.dfActive = active and true or false + restBackdrop(self, accent or GetThemeColor()) + if isTabStyle then + -- Underline tab: show the accent stripe + accent label when active, + -- dim label when inactive. + local a = accent or GetThemeColor() + if self.dfTabStripe then + self.dfTabStripe:SetColorTexture(a.r, a.g, a.b, 1) + self.dfTabStripe:SetShown(self.dfActive) + end + if self.Text then + if self.dfActive then + self.Text:SetTextColor(a.r, a.g, a.b) + else + self.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + end + if fadeActiveText then + -- Status toggles: the active fill+border carry the "on" emphasis, so + -- the label/icon recede slightly when active (settled) and stay bright + -- when inactive (a clearer call to action). Alpha keeps this + -- independent of whatever colour the owner sets on the text/icon. + local a = self.dfActive and 0.7 or 1 + if self.Text then self.Text:SetAlpha(a) end + if self.Icon then self.Icon:SetAlpha(a) end + end + end + + -- Disabled / "greyed out": a dim backdrop + faint border, label/icon dimmed + -- via alpha (keeps their own colour, just recedes), and the hover wash + + -- border suppressed. The button stays natively enabled so a HookScript + -- tooltip can still explain WHY it's disabled; the owner's OnClick must + -- early-out on self.dfDisabled. SetDisabled(false) restores the normal/ + -- active/primary rest. + btn.SetDisabled = function(self, disabled) + disabled = disabled and true or false + -- Idempotent: bail when the state isn't actually changing. Tab refresh + -- paths (the Aura Designer's UpdateLayoutTabState) call SetDisabled(false) + -- on the sub-tabs on EVERY rebuild. Re-running the enable-restore below on + -- an already-enabled tab left the AD Effects/Layout tabs diverging from a + -- never-disabled tab (Global) and broke their hover wash — the only code + -- that ran on them but not on Global was this call. Skipping the no-op keeps + -- an enabled tab identical to one SetDisabled never touched. + if (self.dfDisabled and true or false) == disabled then return end + self.dfDisabled = disabled + if self.dfDisabled then + self:SetBackdropColor(C_ELEMENT.r * 0.55, C_ELEMENT.g * 0.55, C_ELEMENT.b * 0.55, 0.6) + self:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.25) + -- Kill the hover wash through its VERTEX alpha — the same channel + -- ApplyThemeColor uses for the wash's rest STRENGTH. The old object- + -- alpha toggle (hl:SetAlpha 0/1) mixed two alpha channels, so a + -- disable->enable cycle could restore the wash at FULL strength + -- instead of 0.30 (seen live on the Filter Designer add/rename/ + -- delete buttons when switching a preset -> a custom filter). + local wc = accent or GetThemeColor() + hl:SetVertexColor(wc.r, wc.g, wc.b, 0) + if self.Text then self.Text:SetAlpha(0.35) end + if self.Icon then self.Icon:SetAlpha(0.35) end + else + self.ApplyThemeColor(accent or GetThemeColor()) -- re-assert the wash's rest colour + alpha (0.30 / 0.15) + restBackdrop(self, accent or GetThemeColor()) + if self.Text then self.Text:SetAlpha(1) end + if self.Icon then self.Icon:SetAlpha(1) end + end + end + -- The grey loop (RefreshChildStates) greys gated children via widget:SetEnabled. + -- Native Button:SetEnabled blocks clicks but won't dim a custom-backdrop button + -- (its backdrop/Text are custom, not native button regions), so layer a SetAlpha + -- dim on top. We route through native + SetAlpha, NOT SetDisabled — SetDisabled + -- stays natively clickable (it relies on an OnClick dfDisabled early-out the + -- consumer may not have) and fights the hover wash on SetActive toggles. + local nativeSetEnabled = btn.SetEnabled + btn.SetEnabled = function(self, enabled) + nativeSetEnabled(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + end + + btn.ApplyThemeColor(accent or GetThemeColor()) + if not accent then + btn.UpdateTheme = function() btn.ApplyThemeColor(GetThemeColor()) end + local root = opts.themeRoot or btn:GetParent() + if root then + root.ThemeListeners = root.ThemeListeners or {} + table.insert(root.ThemeListeners, btn) + end + end + + -- WHAT HOVERED LOOKS LIKE — the single implementation, so the mouse and a + -- proxying owner cannot drift apart. OnEnter/OnLeave below are thin wrappers. + -- + -- ⚠ NO CURRENT CONSUMER. The Filter Designer's membership button was the one + -- caller and went with the filters merge (FilterRegistry/Options.lua says so). + -- Kept because the pattern recurs and the Lock/UnlockHighlight subtlety below + -- is not obvious enough to want rediscovered. + -- + -- Call btn:SetHovered(true/false) when something ELSE owns the hit area and + -- forwards the click: a list row whose OnClick fires this button's action. + -- The row lighting its button says "this is what clicking the row does", and + -- it separates the button from the row's highlight by HUE, which is far more + -- robust than the couple of hundredths of grey that sit between C_HOVER and + -- C_ELEMENT (the Filter Designer's spell rows are exactly that case). + -- + -- ⚠ Only wire this where the owner's click REALLY performs this button's + -- action. A control the owner does not fire must not light up with it — + -- priming a destructive button that the row will not actually trigger is + -- worse than the legibility problem it would be solving. + -- + -- The wash lives on the HIGHLIGHT layer, which the client shows only for the + -- frame under the mouse, so a proxied hover needs Lock/UnlockHighlight — it + -- cannot just Show() the texture. The real mouseover is unaffected either + -- way: locking an already-hovered button is a no-op, and unlocking one still + -- under the mouse leaves the client's own highlight up. + local function applyHoverState(self, hovered) + if not hovered then + if isTabStyle or ghost then return end + if self:IsEnabled() and not self.dfDisabled then + restBackdrop(self, accent or GetThemeColor()) + end + return + end + -- Re-resolve the wash against the CURRENT theme, exactly as the border does + -- below (see applyWash). Skipped when the caller pinned a fixed accent, and + -- while disabled -- SetDisabled parks the wash at alpha 0 and it must stay + -- parked, or a disabled button would light up under the mouse. + if not accent and not self.dfDisabled then applyWash(GetThemeColor()) end + -- tab/ghost/neutral: only the auto wash, no accent border + if isTabStyle or ghost or neutralHover then return end + if self:IsEnabled() and not self.dfDisabled then + local a = accent or GetThemeColor() + if tinted then + self:SetBackdropBorderColor(a.r, a.g, a.b, 1) -- full accent border on hover + elseif self.dfActive then + -- keep the active border on hover (the wash still brightens the + -- fill, giving the hover cue). + self:SetBackdropBorderColor(a.r * 0.6, a.g * 0.6, a.b * 0.6, 1) + else + -- border darkens to a shade of the accent; the HIGHLIGHT wash + -- brightens the fill. The wash is translucent (0.3), so the + -- full-opacity border still reads DARKER than the fill. Same for + -- primary — it keeps its edge and the brightening fill is the cue. + self:SetBackdropBorderColor(a.r * 0.4, a.g * 0.4, a.b * 0.4, 1) + end + end + end + + -- The proxied entry point. Lock/Unlock is HERE and not in applyHoverState so + -- a real mouseover never locks: a pooled button hidden mid-hover (a list + -- refreshing under a stationary mouse) would miss its OnLeave and come back + -- lit. An owner calling SetHovered accepts that responsibility instead and + -- must clear it when it rebinds the row. + function btn:SetHovered(hovered) + -- Lock/UnlockHighlight are Button-only, and StyleButton is applied to a + -- few plain Frames too; those still get the border half of the state. + if self.LockHighlight then + if hovered then self:LockHighlight() else self:UnlockHighlight() end + end + applyHoverState(self, hovered) + end + + btn:SetScript("OnEnter", function(self) applyHoverState(self, true) end) + btn:SetScript("OnLeave", function(self) applyHoverState(self, false) end) + return btn +end + +function GUI:CreateButton(parent, text, width, height, func, iconName) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + local opts = { width = width or 120, height = height or 22, text = text } + -- Optional leading icon by Media\Icons name (14px to suit the small buttons). + if iconName then + opts.icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, size = 14 } + end + GUI:StyleButton(btn, opts) + btn:SetScript("OnClick", function(self) + if func then func(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + return btn +end + +-- Standard close/dismiss button: a small square danger-toned button showing a +-- "×" glyph. Replaces the many hand-rolled red close buttons on dialogs/panels. +-- opts = { size (20), onClick, tooltip, tone }. +-- tone = nil → dim grey "×" at rest → white on hover (close/dismiss; default) +-- tone = "danger" → RED "×" at rest → brighter red on hover (inline destructive +-- removes: list-item / tag removes). Both keep the red hover wash. +-- A horizontal row of buttons, chained left-to-right with one gap, sized as a +-- single layout slot. Pages were building this by hand every time -- a bare +-- CreateFrame, then SetPoint("LEFT", prev, "RIGHT", 6, 0) per button, plus the +-- HookScript/ShowTooltip pair on any button that needed a tooltip. +-- +-- buttons = { { label, width, onClick, icon, tooltip, key }, ... } +-- tooltip is a ShowTooltip spec ({title, lines, tone}); a button IS its own +-- label, so it takes the whole-widget hover rather than AttachTooltip's +-- label-only hit area (AttachTooltip returns early without a label region). +-- key names the button on row.buttons for a caller that needs it later. +function GUI:CreateButtonRow(parent, buttons, opts) + opts = opts or {} + local gap, h = opts.gap or 6, opts.height or 24 + local row = CreateFrame("Frame", nil, parent) + row:SetSize(opts.width or 540, opts.rowHeight or (h + 4)) + row.buttons = {} + local prev + for i, spec in ipairs(buttons) do + local btn = GUI:CreateButton(row, spec.label, spec.width or 80, h, spec.onClick, spec.icon) + if prev then + btn:SetPoint("LEFT", prev, "RIGHT", gap, 0) + else + btn:SetPoint("LEFT", 0, 0) + end + if spec.tooltip then + btn:HookScript("OnEnter", function(self) GUI:ShowTooltip(self, spec.tooltip) end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + row.buttons[spec.key or i] = btn + prev = btn + end + return row +end + +function GUI:CreateCloseButton(parent, opts) + opts = opts or {} + local size = opts.size or 20 + -- Rest/hover glyph colours: grey→white for dismiss, red→brighter-red for inline + -- destructive removes. The StyleButton red wash + border is shared by both. + local restColor = (opts.tone == "danger") and { r = 0.9, g = 0.45, b = 0.45 } or C_TEXT_DIM + local hoverColor = (opts.tone == "danger") and { r = 1, g = 0.4, b = 0.4 } or { r = 1, g = 1, b = 1 } + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = size, height = size, + tone = "danger", + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close", + size = math.max(8, math.floor(size * 0.55)), + color = restColor, + }, + }) + btn:HookScript("OnEnter", function(self) self.Icon:SetVertexColor(hoverColor.r, hoverColor.g, hoverColor.b) end) + btn:HookScript("OnLeave", function(self) self.Icon:SetVertexColor(restColor.r, restColor.g, restColor.b) end) + btn:SetScript("OnClick", function(self) + if opts.onClick then opts.onClick(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + if opts.tooltip then + btn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = opts.tooltip}) + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + return btn +end + +-- A bare clickable GLYPH: an icon with a hover cue and NO chrome. This is the +-- small affordance that lives inside a row or a card header -- reorder arrows, an +-- eye visibility toggle, a clear-search "x" -- where a button box would be +-- heavier than the thing it acts on. +-- +-- Deliberately its own helper: StyleButton always draws chrome, and +-- CreateCloseButton is specifically the chromed "x". Before this existed, ~16 +-- sites hand-rolled the same three lines (create texture, tint it dim, brighten +-- it in OnEnter and restore in OnLeave). +-- +-- NOT this: a labelled row that merely CONTAINS an icon (a collapsible section +-- header with a title + chevron, a collapse bar). There the click target is the +-- whole row, not the glyph. +-- +-- opts: +-- texture icon path tooltip / onClick +-- size both dims (16) width / height -- button box, when the hit +-- area is deliberately bigger than the art +-- iconSize art size (= size) rotation -- radians, so one arrow texture +-- can serve both directions +-- color rest tint (C_TEXT_DIM) hoverColor (white) +-- +-- Returns the button with .Icon plus: +-- :SetGlyph(texture, color) re-point the art for a state change. The colour +-- passed becomes the new REST colour, so a later OnLeave restores the +-- state rather than snapping back to the original default. +-- :SetGlyphHover(bool) suppress the hover brighten -- an "off" state should +-- not light up under the mouse. +function GUI:CreateGlyphButton(parent, opts) + opts = opts or {} + local size = opts.size or 16 + local iconSize = opts.iconSize or size + + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(opts.width or size, opts.height or size) + + local icon = btn:CreateTexture(nil, "OVERLAY") + icon:SetSize(iconSize, iconSize) + icon:SetPoint("CENTER", 0, 0) + if opts.texture then icon:SetTexture(opts.texture) end + if opts.rotation then icon:SetRotation(opts.rotation) end + btn.Icon = icon + + local function unpackColor(c, dr, dg, db) + if not c then return dr, dg, db end + return c.r or c[1], c.g or c[2], c.b or c[3] + end + local hr, hg, hb = unpackColor(opts.hoverColor, 1, 1, 1) + btn._glyphHover = true + btn._glyphRest = { unpackColor(opts.color, C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) } + icon:SetVertexColor(unpack(btn._glyphRest)) + + function btn:SetGlyph(texture, color) + if texture then self.Icon:SetTexture(texture) end + if color then self._glyphRest = { unpackColor(color) } end + self.Icon:SetVertexColor(unpack(self._glyphRest)) + end + + function btn:SetGlyphHover(enabled) + self._glyphHover = enabled and true or false + end + + btn:SetScript("OnEnter", function(self) + if self._glyphHover then self.Icon:SetVertexColor(hr, hg, hb) end + if opts.tooltip then GUI:ShowTooltip(self, { title = opts.tooltip }) end + end) + btn:SetScript("OnLeave", function(self) + self.Icon:SetVertexColor(unpack(self._glyphRest)) + if opts.tooltip then GUI:HideTooltip() end + end) + if opts.onClick then + btn:SetScript("OnClick", function(self) opts.onClick(self) end) + end + return btn +end + +-- Shared panel/dialog root backdrop: a solid dark panel with an optional 1px +-- border. Centralises the inline SetBackdrop blocks scattered across dialogs and +-- floating panels. opts = { bgAlpha (0.95), border (true), borderColor {r,g,b,a} +-- or {r,g,b,a array} }. +function GUI:CreatePanelBackdrop(frame, opts) + opts = opts or {} + local bg = opts.bgColor or C_PANEL + -- A panel's border is a full-strength 1px line, where the element default is + -- half-alpha, so the border colour is always passed explicitly rather than + -- left to CreateElementBackdrop's default. + local bc = opts.borderColor + return CreateElementBackdrop(frame, { + outline = opts.border ~= false, + bgColor = { bg.r or bg[1], bg.g or bg[2], bg.b or bg[3], + opts.bgAlpha or bg.a or 0.95 }, + borderColor = bc and { bc.r or bc[1], bc.g or bc[2], bc.b or bc[3], + bc.a or bc[4] or 1 } + or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 1 }, + }) +end + +-- Mover chrome: the translucent tinted plate a drag surface wears while the frames +-- are unlocked. This is a SEPARATE helper from CreateElementBackdrop, not a flag on +-- it, because a mover has the opposite job from settings chrome -- it is meant to +-- shout. Giving movers the neutral element look would be a bug, not consistency. +-- +-- The hue comes from the GUI theme constants (C_ACCENT party purple-blue / C_RAID +-- raid orange) instead of a hardcoded literal, so retheming moves the movers too. +-- +-- ⚠ Which POLE is the caller's choice, not GUI.SelectedMode's, because a mover +-- belongs to the thing it moves: the raid mover must stay orange even while the +-- options window happens to be showing a party page. Pass isRaid where the site +-- knows; omit it only where the mover genuinely has no mode, and it will follow +-- the selected mode. +-- +-- opts: +-- isRaid true/false pins the pole; omit to follow GUI.SelectedMode +-- color {r,g,b} or {[1],[2],[3]} -- explicit override, for a mover whose +-- colour is a user setting rather than the theme +-- fillAlpha default 0.30 borderAlpha default 0.80 +-- fill = false outline only edgeSize default 2 +-- +-- The returned frame gains :RefreshMoverTint(), which re-resolves the hue against +-- the theme as it stands now -- call it if the mode changes while a mover is shown. +function GUI:CreateMoverBackdrop(frame, opts) + opts = opts or {} + local c = opts.color + if not c then + if opts.isRaid ~= nil then c = GetThemeColorFor(opts.isRaid) + else c = GetThemeColor() end + end + local r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] + CreateElementBackdrop(frame, { + fill = opts.fill, + edgeSize = opts.edgeSize or 2, + bgColor = { r, g, b, opts.fillAlpha or 0.30 }, + borderColor = { r, g, b, opts.borderAlpha or 0.80 }, + }) + frame.RefreshMoverTint = function(self, newOpts) + return GUI:CreateMoverBackdrop(self, newOpts or opts) + end + return frame +end + +-- The element backdrop, exposed to consumer files (the stylers in this file use +-- the local directly). Nothing outside should be calling SetBackdrop itself -- +-- route it through here so the look, and the border mechanism, stay in one +-- place. See the local for the opts contract: fill, outline, bgColor, +-- borderColor, backdropEdge. +function GUI:CreateElementBackdrop(frame, opts) + return CreateElementBackdrop(frame, opts) +end + +-- ============================================================ +-- DESIGNER TEMPLATE BAR (shared by the Aura / Text Designer editors) +-- Compact row: "Template: [dropdown ▾] [New][Duplicate][Rename][Delete]". +-- NOTE: the saved keys are still auraDesignerPreset(s) / textDesignerPreset(s) -- +-- only the LABELS became "template". The keys are persisted and exported, so +-- renaming them would cost a profile migration for nothing a user can see. +-- "Preset" now means only the built-in filter sets (FilterRegistry) and the +-- export/test quick-picks. +-- Picking a template assigns it to the mode (opts.getMode()) AND retargets the +-- editor; the buttons manage the library. After any change the bar calls +-- opts.onChange() so the host page can rebuild + refresh live frames. +-- opts = { kind = "aura"|"text", getMode = fn->mode, onChange = fn }. +-- Returns the bar frame; call bar:Refresh() to resync. +-- ============================================================ + +-- The addon's ONE name prompt. This was hand-rolled twice against Blizzard's +-- StaticPopup edit box — here and in the Filter Designer — each copy carrying +-- the same `self.EditBox or self.editBox or self:GetEditBox()` fallback for a +-- field name that moves between client versions. Both now come through here and +-- get DF chrome, so there is nothing left to keep in step. +-- opts = { title, message, default, acceptLabel, maxLetters, onAccept(text) } +function GUI:PromptName(opts) + opts = opts or {} + DF:ShowPopupInput({ + title = opts.title, + message = opts.message, + text = opts.default or "", + acceptLabel = opts.acceptLabel, + maxLetters = opts.maxLetters or 40, + onAccept = function(text) + -- Trim here so every caller's uniqueness check and empty-name + -- fallback sees the same thing. + if opts.onAccept then opts.onAccept(strtrim(text or "")) end + end, + }) +end + +local function PromptPresetName(message, default, acceptLabel, callback) + GUI:PromptName({ + title = L["Template Name"], + message = message, + default = default, + acceptLabel = acceptLabel, + onAccept = callback, + }) +end + +local function ConfirmDeletePreset(kind, name, onDone) + DF:ShowPopupAlert({ + title = L["Delete Template"], + message = format(L["Delete template \"%s\"? Anything using it reverts to Default."], name), + buttons = { + { + label = L["Delete"], + onClick = function() + if DF.DeleteDesignerPreset then + DF:DeleteDesignerPreset(kind, name) + if onDone then onDone() end + end + end, + }, + { label = L["Cancel"] }, + }, + }) +end + +function GUI:CreateDesignerPresetBar(parent, opts) + opts = opts or {} + local kind = opts.kind or "aura" + local getMode = opts.getMode or function() return "party" end + local onChange = opts.onChange or function() end + + local bar = CreateFrame("Frame", nil, parent) + bar:SetHeight(24) + + local label = bar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("LEFT", 0, 0) + label:SetText(L["Template:"]) + label:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local function CurrentName() + return DF:GetModeDesignerPresetName(kind, getMode()) + end + + -- True while editing a raid auto-layout (the only context with an "inherit + -- the global preset" choice — normal party/raid modes ARE the base). + -- Mode-gated: auto-layouts are RAID-only, but the GUI can be reopened on + -- the party tab while editing (ToggleGUI re-derives SelectedMode) — the + -- PARTY preset bar must not show layout state, and its "Inherit (Global)" + -- click must never clear the RAID layout's override. + local function IsEditingLayout() + return getMode() == "raid" + and DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() + end + + -- The label to show on the dropdown button: "Inherit (Global)" when the + -- edited layout has no override, otherwise the resolved preset name. + local function CurrentLabel() + if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then + return L["Inherit (Global)"] + end + return CurrentName() + end + + -- Dropdown button + menu (rebuilt on each open so it always reflects the lib) + local ddBtn = CreateFrame("Button", nil, bar, "BackdropTemplate") + ddBtn:SetSize(150, 22) + ddBtn:SetPoint("LEFT", label, "RIGHT", 6, 0) + CreateElementBackdrop(ddBtn) + ddBtn.text = ddBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + ddBtn.text:SetPoint("LEFT", 6, 0) + ddBtn.text:SetPoint("RIGHT", -16, 0) + ddBtn.text:SetJustifyH("LEFT") + ddBtn.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local arrow = ddBtn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -4, 0) + arrow:SetSize(10, 10) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local menu = CreateFrame("Frame", nil, ddBtn, "BackdropTemplate") + menu:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menu) + menu:SetPoint("TOPLEFT", ddBtn, "BOTTOMLEFT", 0, -1) + menu:SetWidth(150) + CreatePanelBackdrop(menu) + menu:Hide() + + -- Row pool: frames can't be garbage-collected in WoW, so recreating the + -- items on every open (the old Hide+SetParent(nil) approach) leaked a row + -- set per click. Reuse instead. + local menuRows = {} + local function BuildMenu() + for _, row in ipairs(menuRows) do row:Hide() end + local used = 0 + local y = -4 + local function AddItem(label, onClick) + used = used + 1 + local item = menuRows[used] + if not item then + item = CreateFrame("Button", nil, menu) + item:SetHeight(20) + item.text = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + item.text:SetPoint("LEFT", 4, 0) + item:SetScript("OnEnter", function(s) s.text:SetTextColor(1, 1, 1) end) + item:SetScript("OnLeave", function(s) s.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end) + item:SetScript("OnClick", function(s) + s.onClick() + menu:Hide() + bar:Refresh() + onChange() + end) + menuRows[used] = item + end + item:ClearAllPoints() + item:SetPoint("TOPLEFT", 4, y) + item:SetPoint("TOPRIGHT", -4, y) + item.text:SetText(label) + item.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + item.onClick = onClick + item:Show() + y = y - 20 + end + -- "Inherit (Global)" — only while editing a raid auto-layout. Clears the + -- layout's preset override so it follows your global preset. + if IsEditingLayout() then + AddItem(L["Inherit (Global)"], function() + if DF.InheritLayoutDesignerPreset then DF:InheritLayoutDesignerPreset(kind) end + end) + end + for _, name in ipairs(DF:ListDesignerPresets(kind)) do + AddItem(name, function() DF:SetModeDesignerPreset(kind, getMode(), name) end) + end + menu:SetHeight(-y + 4) + end + ddBtn:SetScript("OnClick", function() + if menu:IsShown() then menu:Hide() else BuildMenu(); menu:Show() end + end) + + -- SHARING MARKER. A template can be pointed at by the other mode, a pinned + -- set or an auto layout, and editing it then changes every one of them — + -- which nothing on this bar used to say. The dropdown is a fixed 150px, so + -- the FACT rides as a glyph and the NAMES go in the tooltip, which is free. + -- + -- Deliberately NOT clickable. Splitting a shared template off for this mode + -- is exactly what Duplicate does, two buttons to the right, and Duplicate + -- also lets you name the copy. + local shareIcon = ddBtn:CreateTexture(nil, "OVERLAY") + shareIcon:SetSize(12, 12) + shareIcon:SetPoint("RIGHT", arrow, "LEFT", -3, 0) + shareIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync") + shareIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + shareIcon:Hide() + + -- Read off the REFS, not the party/raid sync flag: sharing by hand (picking + -- the other mode's preset from this dropdown) counts exactly the same, and a + -- ref can't fall out of step with itself. + local function SharedWith() + if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then + return {} -- inheriting: this bar isn't sitting on a preset of its own + end + return (DF.ListDesignerPresetUsers and DF:ListDesignerPresetUsers(kind, CurrentName(), getMode())) or {} + end + + -- The consumers (Party/Raid, Auto Layouts, Pinned Frames) are spread across + -- three other pages, so naming them is the one thing this tooltip has to do + -- — the bar itself already shows what a template is. "can" holds for all + -- four: Auto Layouts and Pinned Frames may inherit their mode's instead + -- (a nil ref), and Party/Raid resolve to a default when nothing is set. + -- Names match their page titles so they are findable. + ddBtn:SetScript("OnEnter", function(self) + local lines = { + L["Templates can be used by Party, Raid, Auto Layouts and Pinned Frames."], + } + local shared = SharedWith() + if #shared > 0 then + lines[#lines + 1] = " " + lines[#lines + 1] = { text = format(L["Also used by: %s"], table.concat(shared, ", ")), accent = true } + -- "there" points back at the list above, so the consequence needs no + -- nouns of its own. It has to be said: a shared template's edits + -- reach a screen you are not looking at, and naming the users is + -- only the fact, not the warning. + lines[#lines + 1] = L["Edits apply there too."] + end + GUI:ShowTooltip(self, { title = L["Templates"], lines = lines }) + end) + ddBtn:SetScript("OnLeave", function() GUI:HideTooltip() end) + + -- When editing a raid auto-layout, default the NEW preset name to the + -- layout's name (e.g. editing "31-40" → prefill "31-40") so making a + -- per-layout preset is one click + Enter. nil (blank) otherwise. (Duplicate + -- names after its source preset, not the layout.) + local function EditingLayoutName() + if not IsEditingLayout() then return nil end -- mode-gated (raid only) + local apu = DF.AutoProfilesUI + if apu and apu.editingProfile then + return apu.editingProfile.name + end + return nil + end + + -- Action buttons. opts.iconButtons = true swaps the labeled buttons for + -- compact tooltipped icon-only buttons (22x22) — used where the bar shares + -- a row with other controls (Aura Designer header). Default stays labeled + -- (Text Designer) so existing callers are untouched. + local function CreateAction(labelText, iconName, width, onClick) + if opts.iconButtons then + local b = CreateFrame("Button", nil, bar, "BackdropTemplate") + GUI:StyleButton(b, { + width = 22, height = 22, + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, + size = 14, color = C_TEXT, + }, + }) + b:SetScript("OnClick", function(self) + onClick(self) + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + b:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = labelText}) + end) + b:HookScript("OnLeave", function() GUI:HideTooltip() end) + return b + end + return GUI:CreateButton(bar, labelText, width, 22, onClick) + end + + local newBtn = CreateAction(L["New"], "add", 48, function() + PromptPresetName(L["Name the new template:"], EditingLayoutName() or "", L["Create"], function(text) + local n = DF:CreateDesignerPreset(kind, text) + if n then + DF:SetModeDesignerPreset(kind, getMode(), n) + bar:Refresh(); onChange() + end + end) + end) + newBtn:SetPoint("LEFT", ddBtn, "RIGHT", 6, 0) + + local dupBtn = CreateAction(L["Duplicate"], "content_copy", 72, function() + local cur = CurrentName() + -- Duplicate defaults to " copy" (New uses the layout name, but a + -- duplicate is of a specific preset, so name it after the source). + PromptPresetName(L["Name the duplicated template:"], cur .. " copy", L["Duplicate"], function(text) + local n = DF:DuplicateDesignerPreset(kind, cur, text) + if n then + DF:SetModeDesignerPreset(kind, getMode(), n) + bar:Refresh(); onChange() + end + end) + end) + dupBtn:SetPoint("LEFT", newBtn, "RIGHT", 4, 0) + + local renameBtn = CreateAction(L["Rename"], "edit", 62, function() + local cur = CurrentName() + if cur == DF.DEFAULT_PRESET then return end + PromptPresetName(L["Rename template:"], cur, L["Rename"], function(text) + DF:RenameDesignerPreset(kind, cur, text) + bar:Refresh(); onChange() + end) + end) + renameBtn:SetPoint("LEFT", dupBtn, "RIGHT", 4, 0) + + local delBtn = CreateAction(L["Delete"], "delete", 56, function() + local cur = CurrentName() + if cur == DF.DEFAULT_PRESET then return end + ConfirmDeletePreset(kind, cur, function() bar:Refresh(); onChange() end) + end) + delBtn:SetPoint("LEFT", renameBtn, "RIGHT", 4, 0) + + local function SetActionEnabled(btn, on) + if on then + btn:Enable() + if btn.Text then btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end + if btn.Icon then btn.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end + else + btn:Disable() + -- greyed: Default can't be renamed/deleted + if btn.Text then btn.Text:SetTextColor(0.4, 0.4, 0.4) end + if btn.Icon then btn.Icon:SetVertexColor(0.4, 0.4, 0.4) end + end + end + + function bar:Refresh() + ddBtn.text:SetText(CurrentLabel()) + -- Make room for the share glyph only while it's up, so an unshared + -- preset keeps the full label width. + local isShared = #SharedWith() > 0 + shareIcon:SetShown(isShared) + ddBtn.text:ClearAllPoints() + ddBtn.text:SetPoint("LEFT", 6, 0) + ddBtn.text:SetPoint("RIGHT", isShared and -30 or -16, 0) + -- Rename/Delete act on the resolved preset; disable for the non-editable + -- Default and while a layout is inheriting (you're following the global, + -- not sitting on a layout-specific preset). + local inheriting = IsEditingLayout() and DF.IsLayoutDesignerInheriting + and DF:IsLayoutDesignerInheriting(kind) + local canModify = (CurrentName() ~= DF.DEFAULT_PRESET) and not inheriting + SetActionEnabled(renameBtn, canModify) + SetActionEnabled(delBtn, canModify) + end + + bar:Refresh() + -- The sharing glyph reflects OTHER refs (the other mode, a pinned set, an + -- auto layout), which can change without changing THIS mode's — and both + -- designer pages early-return their rebuild when their own preset is + -- unchanged, so the glyph would sit stale. Register the live bar so the + -- shared page refresh can reach it. One slot per kind: a rebuilt page + -- overwrites its own entry, and a torn-down bar is hidden, so nothing + -- accumulates. + GUI._designerPresetBars = GUI._designerPresetBars or {} + GUI._designerPresetBars[kind] = bar + return bar +end + +function GUI:RefreshDesignerPresetBars() + for _, bar in pairs(GUI._designerPresetBars or {}) do + if bar.Refresh and bar:IsShown() then bar:Refresh() end + end +end + +-- Creates a button with an icon and text +-- iconName is the name of the icon file (without path/extension) +-- iconSize is optional (defaults to 16) +-- align: "center" (default) or "left". Pass "left" for wide / full-width +-- list-style buttons where centred content floats (see GUI:StyleButton). +function GUI:CreateIconButton(parent, iconName, text, width, height, func, iconSize, align) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = width or 120, height = height or 22, + text = text, + align = align, + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, + size = iconSize or 18, + color = C_TEXT, + }, + }) + + btn:SetScript("OnClick", function(self) + if func then func(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + return btn +end + +-- Creates a \"See Also:\" section with clickable links to related pages +-- links = { {pageId = \"display_tooltips\", label = \"Tooltips\"}, ... } +function GUI:CreateSeeAlso(parent, links) + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + container:SetHeight(32) + -- The page's own footer: pinned to the bottom of the viewport on a SHORT + -- page instead of floating wherever the content happened to end. See the + -- footer block in the page layout. + container.isPageFooter = true + CreateElementBackdrop(container, { + bgColor = { 0.1, 0.1, 0.1, 0.5 }, + borderColor = { 0.3, 0.3, 0.3, 0.8 }, + }) + + local label = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("TOPLEFT", 8, -10) + label:SetText(L["See Also:"]) + label:SetTextColor(0.7, 0.7, 0.7) + + local linkButtons = {} + local separators = {} + + for i, linkData in ipairs(links) do + local link = CreateFrame("Button", nil, container) + link:SetHeight(16) + + local linkText = link:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + linkText:SetPoint("TOPLEFT", 0, -1) + linkText:SetText(linkData.label) + local c = GetThemeColor() + linkText:SetTextColor(c.r, c.g, c.b) + link.text = linkText + link.textWidth = linkText:GetStringWidth() + 4 + link:SetWidth(link.textWidth) + + link:SetScript("OnEnter", function(self) + local h = GUI:LinkHoverColor(c) + linkText:SetTextColor(h.r, h.g, h.b) + end) + link:SetScript("OnLeave", function(self) + linkText:SetTextColor(c.r, c.g, c.b) + end) + link:SetScript("OnClick", function() + if GUI.SelectTab then + GUI.SelectTab(linkData.pageId) + end + end) + + table.insert(linkButtons, link) + + -- Create separator (hidden by default, shown as needed) + if i < #links then + local sep = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + sep:SetText("•") + sep:SetTextColor(0.5, 0.5, 0.5) + table.insert(separators, sep) + end + end + + -- Layout function that handles wrapping + local function LayoutLinks() + local containerWidth = container:GetWidth() + if containerWidth < 50 then return end -- Not sized yet + + local labelWidth = label:GetStringWidth() + 16 + local firstLinkX = labelWidth -- Where first link starts + local xOffset = labelWidth + local yOffset = -9 + local lineHeight = 18 + local maxX = containerWidth - 10 + local rowCount = 1 + + -- First pass: determine which links are on which row + local linkRows = {} + local tempX = labelWidth + local currentRow = 1 + + for i, link in ipairs(linkButtons) do + local linkWidth = link.textWidth + local sepWidth = (i < #linkButtons) and 14 or 0 + + -- Check if we need to wrap + if tempX + linkWidth > maxX and tempX > labelWidth then + currentRow = currentRow + 1 + tempX = firstLinkX + end + + linkRows[i] = currentRow + tempX = tempX + linkWidth + sepWidth + end + + rowCount = currentRow + + -- Second pass: position elements + xOffset = labelWidth + local lastRowForLink = 1 + + for i, link in ipairs(linkButtons) do + local linkWidth = link.textWidth + + -- Check if we need to wrap to new line + if linkRows[i] > lastRowForLink then + xOffset = firstLinkX + yOffset = yOffset - lineHeight + lastRowForLink = linkRows[i] + end + + link:ClearAllPoints() + link:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset) + + xOffset = xOffset + linkWidth + 2 + + -- Position separator only if next link is on same row + if separators[i] then + if linkRows[i + 1] == linkRows[i] then + separators[i]:ClearAllPoints() + separators[i]:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset - 1) + separators[i]:Show() + xOffset = xOffset + 12 + else + separators[i]:Hide() + end + end + end + + -- Adjust container height based on rows. + -- + -- Snapped HERE, where the number is computed, because this widget + -- MEASURES ITSELF: LayoutLinks runs from OnSizeChanged and from a + -- C_Timer.After(0), i.e. a frame AFTER the page layout has run. + -- Correcting the height after the fact cannot win -- whatever sets a + -- grid-aligned height, this function overwrites it on the next frame, + -- and the bar's bottom edge ends up split across two device rows. + -- Measured: 28 units at 1.40625 px/unit is 39.375px, 0.375 off. + local newHeight = SnapLen(container, 10 + (rowCount * lineHeight)) + container:SetHeight(newHeight) + container.layoutHeight = newHeight + 5 + end + + container:SetScript("OnSizeChanged", LayoutLinks) + + -- Initial layout after a frame (to let width be set) + C_Timer.After(0, LayoutLinks) + + return container +end + +-- ========================================================================= +-- OVERRIDE INDICATORS FOR AUTO PROFILES +-- ========================================================================= +-- Helper function to add override indicators (star, reset button, global value text) +-- to widget containers when editing an auto profile + +-- Debug flag - when true, shows all reset buttons regardless of override state +S.overrideDebugMode = false + +-- Track all widgets with override indicators for refresh +local overrideWidgets = {} + +-- Function to check if debug mode is active (exposed for other files) +local function IsOverrideDebugMode() + return S.overrideDebugMode +end +GUI.IsOverrideDebugMode = IsOverrideDebugMode + +-- Function to refresh all override indicators +local function RefreshAllOverrideIndicators() + for _, widget in ipairs(overrideWidgets) do + if widget and widget.UpdateOverrideIndicators then + widget:UpdateOverrideIndicators() + end + end + -- Also refresh position override indicator + if GUI.UpdatePositionOverrideIndicator then + GUI.UpdatePositionOverrideIndicator() + end + -- Refresh tab override stars (auto-profiles) + if DF.AutoProfilesUI and DF.AutoProfilesUI.RefreshTabOverrideStars then + DF.AutoProfilesUI:RefreshTabOverrideStars() + end +end +GUI.RefreshAllOverrideIndicators = RefreshAllOverrideIndicators + +-- Allow other files to register widgets with override indicators +function GUI.RegisterOverrideWidget(widget) + table.insert(overrideWidgets, widget) +end + +-- NOT a dump, despite what the old description ("Auto layout override table +-- dump") claimed — it prints no table. It forces every reset button / override +-- marker visible regardless of override state, so you can see which controls +-- carry the machinery at all. S.overrideDebugMode is live: read by +-- GUI.IsOverrideDebugMode and three marker call sites. +DF:RegisterDebugSlash("DFOVERRIDEDEBUG", "Force-show every override marker and reset button", true, "/dfoverridedebug") +SlashCmdList["DFOVERRIDEDEBUG"] = function() + S.overrideDebugMode = not S.overrideDebugMode + DF:Say("Override debug mode " .. (S.overrideDebugMode and "ENABLED" or "DISABLED")) + -- Refresh all override indicators + RefreshAllOverrideIndicators() + -- Also update position panel if open + if DF.positionPanel and DF.positionPanel.UpdatePositionOverride then + DF.positionPanel.UpdatePositionOverride() + end +end + +-- ============================================================ +-- SHARED OVERRIDE CONTROLS +-- One "override active" marker (a coloured dot) + one "reset to global" button +-- (red, icon-only, danger tone — reads like the Reset Page button), so every +-- override control across the addon speaks one visual language. Callers create +-- them, position the returned frames, and toggle Show/Hide. +-- ============================================================ +-- Single source of truth for the override-marker colour. +GUI.OVERRIDE_MARKER_COLOR = { 1, 0.8, 0.2 } + +-- A coloured dot marking "this setting is overridden". Returns a hidden Button +-- so the caller can set .tooltipText / .tooltipSubText for a hover tooltip. +-- `size` = dot diameter in px (default 12); the hit frame is a touch larger. +function GUI:CreateOverrideMarker(parent, size) + size = size or 12 + local c = GUI.OVERRIDE_MARKER_COLOR + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(size + 6, size + 6) + -- Only ever a hover-tooltip target; let clicks fall through so a marker + -- placed on a clickable parent (e.g. a nav tab) doesn't eat its clicks. + -- SetPropagateMouseClicks is PROTECTED on 12.1 (ADDON_ACTION_BLOCKED if called + -- under combat lockdown — e.g. opening/refreshing settings in combat); skip it + -- there. Only matters when the marker sits on a clickable parent, and combat + -- GUI edits are the rare case. + if btn.SetPropagateMouseClicks and not InCombatLockdown() then btn:SetPropagateMouseClicks(true) end + local icon = btn:CreateTexture(nil, "OVERLAY") + icon:SetPoint("CENTER") + icon:SetSize(size, size) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\dot") + icon:SetVertexColor(c[1], c[2], c[3]) + btn.icon = icon + btn:SetScript("OnEnter", function(s) + if s.tooltipText then + GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipSubText and { s.tooltipSubText } or nil }) + end + end) + btn:SetScript("OnLeave", function() GUI:HideTooltip() end) + btn:Hide() + return btn +end + +-- A bare checkbox sized for a LIST ROW — no label, no db binding, no settings-row +-- geometry. GUI:CreateCheckbox is a whole 30px settings row with its own label and +-- hit rect, which is the wrong shape entirely inside a 22px pooled list row that +-- already owns its own text, count and selection accent. +-- +-- The caller drives it: :SetChecked(bool) to paint, opts.onClick to react. It does +-- NOT read or write the db itself, because a list row's meaning changes per bind +-- (a pooled row is a different filter every refresh) and a captured dbKey would go +-- stale the moment the pool rebinds. +-- +-- ⚠ Clicks deliberately do NOT propagate. The override marker above lets them fall +-- through because it is a passive marker; this is a control, and on a clickable row +-- the two gestures must stay separate — tick the box to switch the filter on, click +-- anywhere else to select it. Nothing here calls SetPropagateMouseClicks, which is +-- PROTECTED on 12.1 anyway (see the note in CreateOverrideMarker). +-- +-- ⚠ The BOX ITSELF is GUI:StyleCheckButton, the addon's one checkbox look — do not +-- hand-roll it again. This was hand-rolled once and drifted four ways: it drew the +-- Media\Icons\check GLYPH where every other checkbox in the addon draws a filled +-- WHITE8x8 square (a different SYMBOL, not a different style), it skipped PixelUtil +-- so it alone was unsnapped, it had no hover wash, and it recoloured its BORDER when +-- checked, which nothing else does. CreateDebugCategoryRow is the precedent for this +-- exact case — a list row with a checkbox — at the same size. +-- +-- manualCheck because this is a plain Button, not a CheckButton: SetChecked below +-- drives the mark. A real CheckButton would draw its checked mark through the native +-- checked state, which has no disabled-checked texture here — so a greyed-but-ticked +-- row (every filter row while All Buffs is on) would lose its tick entirely. +-- +-- opts: { size = 16, checkSize = 9, onClick = function(checked) end, +-- tooltip = title, tooltipDesc = line } +function GUI:CreateRowToggle(parent, opts) + opts = opts or {} + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleCheckButton(btn, { + size = opts.size or 16, + checkSize = opts.checkSize or 9, + manualCheck = true, + }) + + btn.checked = false + function btn:SetChecked(on) + self.checked = on and true or false + -- Re-tint on every paint. StyleCheckButton registers its theme listener on + -- this button's PARENT, which for a pooled list row is a frame inside a + -- scroll child that the page's theme walk never visits (same trap as + -- StyleButton's wash). The list rebinds every row on refresh, and a refresh + -- is what a mode switch produces, so painting the accent here is what + -- actually carries party purple -> raid orange. + self.ApplyThemeColor(GetThemeColor()) + self.Check:SetShown(self.checked) + end + + btn:SetScript("OnClick", function(s) + if s.onClick then s.onClick(not s.checked) end + end) + btn:SetScript("OnEnter", function(s) + if s.tooltipText then + GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipDesc and { s.tooltipDesc } or nil }) + end + end) + btn:SetScript("OnLeave", function() GUI:HideTooltip() end) + + btn.onClick = opts.onClick + btn.tooltipText = opts.tooltip + btn.tooltipDesc = opts.tooltipDesc + btn:SetChecked(false) + return btn +end + +-- A "reset to global" button — red, icon-only, danger tone (matches the Reset +-- Page button). Returns a hidden Button. opts: { size = 18, tooltip = title, +-- tooltipDesc = line, onClick = fn }. +function GUI:CreateOverrideResetButton(parent, opts) + opts = opts or {} + local size = opts.size or 18 + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = size, height = size, + tone = "danger", + icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\refresh", size = size - 6 }, + }) + btn:Hide() + -- StyleButton owns OnEnter (hover wash); hook the tooltip on top. + btn:HookScript("OnEnter", function(self) + if opts.tooltip then + GUI:ShowTooltip(self, { title = opts.tooltip, lines = opts.tooltipDesc and { opts.tooltipDesc } or nil }) + end + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + if opts.onClick then btn:SetScript("OnClick", opts.onClick) end + return btn +end + +local function AddOverrideIndicators(container, lbl, dbKey, onReset, verticalOffset, optionsMap, dbTable) + -- Skip for proxy tables (e.g. Aura Designer) that don't support per-key override tracking + if dbTable and rawget(dbTable, "_skipOverrideIndicators") then return end + verticalOffset = verticalOffset or 0 + container.overrideOptionsMap = optionsMap + + -- Reset button (red, icon-only) at top-right; the override marker (dot) + -- sits to its left. Both are shared helpers (GUI:CreateOverride*). + local resetBtn = GUI:CreateOverrideResetButton(container, { + tooltip = L["Reset to Global"], + tooltipDesc = L["Reset this setting to its global value."], + onClick = function() if onReset then onReset() end end, + }) + resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, verticalOffset) + container.overrideResetBtn = resetBtn + + local starBtn = GUI:CreateOverrideMarker(container) + starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) + container.overrideStar = starBtn + + -- Global value text (shown when in edit mode) - positioned inline after label + local globalText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + globalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + globalText:SetTextColor(0.4, 0.4, 0.4) + globalText:Hide() + container.overrideGlobalText = globalText + + -- Checkmark icon for matching global value + local checkIcon = container:CreateTexture(nil, "OVERLAY") + checkIcon:SetSize(8, 8) + checkIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\check") + checkIcon:SetVertexColor(0.3, 0.7, 0.3) + checkIcon:Hide() + container.overrideCheckIcon = checkIcon + + -- Store dbKey for reference + container.overrideDbKey = dbKey + + -- Function to update override indicators + container.UpdateOverrideIndicators = function(self, currentValue) + -- Debug mode shows all buttons + if S.overrideDebugMode then + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideGlobalText:SetText("(debug)") + self.overrideGlobalText:SetTextColor(1, 0.8, 0.2) -- Yellow for visibility + self.overrideGlobalText:Show() + self.overrideCheckIcon:Hide() + return + end + + -- Only show when in raid mode + local GUI = DF.GUI + if not GUI or GUI.SelectedMode ~= "raid" then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide() + self.overrideCheckIcon:Hide() + return + end + + local AutoProfilesUI = DF.AutoProfilesUI + local isEditing = AutoProfilesUI and AutoProfilesUI:IsEditing() + local isRuntimeOverridden = AutoProfilesUI and AutoProfilesUI:IsOverriddenByRuntime(dbKey) + + -- Hide everything if not editing AND not runtime-overridden + if not isEditing and not isRuntimeOverridden then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideGlobalText:Hide() + self.overrideCheckIcon:Hide() + return + end + + -- Runtime override mode: show star + global value, but no reset button + if isRuntimeOverridden and not isEditing then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting is being overridden by the active auto layout profile. To change it, edit the profile in the Auto Layouts tab."] + self.overrideStar:Show() + self.overrideResetBtn:Hide() -- Can't reset runtime overrides from controls + self.overrideCheckIcon:Hide() + + local globalValue = AutoProfilesUI:GetRuntimeGlobalValue(dbKey) + + -- Format global value for display + local globalDisplay + if type(globalValue) == "boolean" then + globalDisplay = globalValue and L["Yes"] or L["No"] + elseif type(globalValue) == "number" then + if globalValue == math.floor(globalValue) then + globalDisplay = tostring(globalValue) + else + globalDisplay = string.format("%.2f", globalValue) + end + elseif type(globalValue) == "table" then + if globalValue.r then + globalDisplay = L["Color"] + else + globalDisplay = "..." + end + elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then + local mapped = self.overrideOptionsMap[globalValue] + if type(mapped) == "table" then + globalDisplay = mapped.text or mapped.label or globalValue + else + globalDisplay = tostring(mapped) + end + else + globalDisplay = tostring(globalValue or L["None"]) + end + + self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) + self.overrideGlobalText:ClearAllPoints() + self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) + self.overrideGlobalText:Show() + return + end + + -- Editing mode: existing behavior + -- Check if setting is overridden + local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) + local globalValue = AutoProfilesUI:GetGlobalValue(dbKey) + + -- Show/hide star and reset button + if isOverridden then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] + self.overrideStar:Show() + self.overrideResetBtn:Show() + else + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + end + + -- Format global value for display + local globalDisplay + if type(globalValue) == "boolean" then + globalDisplay = globalValue and L["Yes"] or L["No"] + elseif type(globalValue) == "number" then + if globalValue == math.floor(globalValue) then + globalDisplay = tostring(globalValue) + else + globalDisplay = string.format("%.2f", globalValue) + end + elseif type(globalValue) == "table" then + -- Color table + if globalValue.r then + globalDisplay = L["Color"] + else + globalDisplay = "..." + end + elseif type(globalValue) == "string" and self.overrideOptionsMap and self.overrideOptionsMap[globalValue] then + local mapped = self.overrideOptionsMap[globalValue] + if type(mapped) == "table" then + globalDisplay = mapped.text or mapped.label or globalValue + else + globalDisplay = tostring(mapped) + end + else + globalDisplay = tostring(globalValue or L["None"]) + end + + -- Show global value inline with label + self.overrideGlobalText:SetText(string.format(L["(Global: %s)"], globalDisplay)) + self.overrideGlobalText:ClearAllPoints() + self.overrideGlobalText:SetPoint("LEFT", lbl, "RIGHT", 4, 0) + + if isOverridden then + self.overrideGlobalText:SetTextColor(0.5, 0.5, 0.5) + self.overrideCheckIcon:Hide() + else + self.overrideGlobalText:SetTextColor(0.3, 0.6, 0.3) + -- Position check icon after text + self.overrideCheckIcon:ClearAllPoints() + self.overrideCheckIcon:SetPoint("LEFT", self.overrideGlobalText, "RIGHT", 2, 0) + self.overrideCheckIcon:Show() + end + self.overrideGlobalText:Show() + end + + -- Register this widget for refresh tracking + table.insert(overrideWidgets, container) + + return container +end +P.AddOverrideIndicators = AddOverrideIndicators + +-- Override indicators for order list controls (drag lists) +-- These don't have traditional labels, so we use a compact star + reset + "Modified" badge +local function AddOrderListOverrideIndicators(container, dbKey, onReset) + -- Reset button (red, icon-only) + override marker (dot) — shared helpers. + local resetBtn = GUI:CreateOverrideResetButton(container, { + tooltip = L["Reset to Global Order"], + onClick = function() if onReset then onReset() end end, + }) + resetBtn:SetPoint("TOPRIGHT", container, "TOPRIGHT", 0, 14) + container.overrideResetBtn = resetBtn + + local starBtn = GUI:CreateOverrideMarker(container) + starBtn:SetPoint("RIGHT", resetBtn, "LEFT", -2, 0) + container.overrideStar = starBtn + local starIcon = starBtn.icon -- the "Modified" badge below anchors to the dot + + -- "Modified" text to the left of star + local modifiedText = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + modifiedText:SetPoint("RIGHT", starIcon, "LEFT", -2, 0) + modifiedText:SetText(L["Modified"]) + modifiedText:SetTextColor(1, 0.8, 0.2, 0.8) + modifiedText:Hide() + container.overrideModifiedText = modifiedText + + -- Store dbKey for reference + container.overrideDbKey = dbKey + + -- Update function + container.UpdateOverrideIndicators = function(self, currentValue) + -- Debug mode + if S.overrideDebugMode then + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideModifiedText:SetText("Modified (debug)") + self.overrideModifiedText:Show() + return + end + + -- Only show when in raid mode and editing + local GUI = DF.GUI + if not GUI or GUI.SelectedMode ~= "raid" then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideModifiedText:Hide() + return + end + + local AutoProfilesUI = DF.AutoProfilesUI + if not AutoProfilesUI or not AutoProfilesUI:IsEditing() then + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideModifiedText:Hide() + return + end + + local isOverridden = AutoProfilesUI:IsSettingOverridden(dbKey) + + if isOverridden then + self.overrideStar.tooltipText = L["Override active"] + self.overrideStar.tooltipSubText = L["This setting differs from the global profile value. Click the reset button to revert."] + self.overrideStar:Show() + self.overrideResetBtn:Show() + self.overrideModifiedText:Show() + else + self.overrideStar:Hide() + self.overrideResetBtn:Hide() + self.overrideModifiedText:Hide() + end + end + + -- Register for refresh tracking + table.insert(overrideWidgets, container) +end +P.AddOrderListOverrideIndicators = AddOrderListOverrideIndicators + +-- ============================================================ +-- SHARED CHECK / RADIO LOOK — single source of truth +-- Applies the standard square look to a CheckButton: element +-- backdrop + a pixel-snapped, themed WHITE8x8 check. Every box +-- and radio (the full builders below AND the hand-rolled ones +-- elsewhere) should call this, so a restyle is ONE edit. +-- opts.size box size (default 18) +-- opts.checkSize check-square size (default 10) +-- opts.accent fixed tint {r,g,b} — e.g. ClickCasting's green. +-- Omit to follow the party/raid theme (and auto- +-- register a theme listener on opts.themeRoot). +-- opts.themeRoot frame whose .ThemeListeners drive recolor +-- (default the button's parent); only used when +-- no accent is given. +-- Returns the check texture (also stored as cb.Check). +-- ============================================================ +function GUI:StyleCheckButton(cb, opts) + opts = opts or {} + PixelUtil.SetSize(cb, opts.size or 18, opts.size or 18) + CreateElementBackdrop(cb) + + local check = cb.Check or cb:CreateTexture(nil, "OVERLAY") + check:SetTexture("Interface\\Buttons\\WHITE8x8") + local cs = opts.checkSize or 10 + PixelUtil.SetSize(check, cs, cs) + PixelUtil.SetPoint(check, "CENTER", cb, "CENTER", 0, 0) + + local accent = opts.accent + local col = accent or GetThemeColor() + cb.Check = check + -- Native checkboxes let WoW show/hide the check via the checked state; a few + -- consumers (and plain Button-based pseudo-checkboxes) drive it manually via + -- cb.Check:SetShown(). opts.manualCheck supports those without SetCheckedTexture. + if opts.manualCheck then + check:Hide() + else + cb:SetCheckedTexture(check) + end + + -- Hover feedback: a subtle accent wash on the native HIGHLIGHT layer. WoW + -- shows it on mouseover automatically, so it works regardless of any OnEnter + -- the consumer sets (no clobbering), and it doesn't recolor the 1px border + -- (which can render unevenly at fractional UI scales). + local hl = cb:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture("Interface\\Buttons\\WHITE8x8") + hl:SetAllPoints(cb) + cb.Highlight = hl + + -- Single source for the themed tint: check colour + hover-wash strength. + -- Consumers that drive their own theme refresh call cb.ApplyThemeColor(c) too, + -- so the wash alpha (0.35) is defined in exactly one place. + cb.ApplyThemeColor = function(c) + check:SetVertexColor(c.r, c.g, c.b) + hl:SetVertexColor(c.r, c.g, c.b, 0.35) + end + cb.ApplyThemeColor(col) + + if not accent then + cb.UpdateTheme = function() + cb.ApplyThemeColor(GetThemeColor()) + end + local root = opts.themeRoot or cb:GetParent() + if root then + root.ThemeListeners = root.ThemeListeners or {} + table.insert(root.ThemeListeners, cb) + end + end + return check +end + +function GUI:CreateCheckbox(parent, label, dbTable, dbKey, callback, customGet, customSet, overrideKey) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(220, 24) + container.preferredHeight = GUI.RowHeight.checkbox -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "checkbox" -- /df debug gapcheck groups the spacing report by this + container.fixedRowHeight = true + + local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") + cb:SetPoint("LEFT", 0, 0) + -- Box + themed check come from the shared styler (single source of truth). + GUI:StyleCheckButton(cb, { themeRoot = parent }) + + -- Label + local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + txt:SetText(label) + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + container.label = txt -- exposed so callers can re-font / anchor a subtitle + + -- Determine the key to use for override indicators + local effectiveOverrideKey = overrideKey or dbKey + + -- Add override indicators if we have a key (either dbKey or overrideKey) + if effectiveOverrideKey and type(effectiveOverrideKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(effectiveOverrideKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(effectiveOverrideKey) + cb:SetChecked(globalVal) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + elseif customSet then + customSet(globalVal) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(container, txt, effectiveOverrideKey, onReset, nil, nil, dbTable) + end + + local function UpdateState() + local val = false + if customGet then val = customGet() elseif dbTable and dbKey then val = dbTable[dbKey] end + cb:SetChecked(val) + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + + container:SetScript("OnShow", UpdateState) + -- Re-read the source and repaint the box, for a caller that changed the value + -- behind the widget's back (a "set all" button). Same contract as + -- CreateSegmentToggle:Refresh(); before this, call sites reached for a + -- Hide()/Show() bounce to fire the OnShow above. + container.Refresh = UpdateState + cb:SetScript("OnClick", function(self) + local val = self:GetChecked() + -- Was gated on DF.debugEnabled and printed straight to CHAT, bypassing the + -- console entirely. GUI is the right category and it is already declared. + DF:Debug("GUI", "checkbox OnClick: dbKey=%s overrideKey=%s value=%s", + tostring(dbKey), tostring(overrideKey), tostring(val)) + + -- Runtime override protection: redirect to baseline, skip refresh + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(effectiveOverrideKey, val) then + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end + return + end + + if customSet then customSet(val) elseif dbTable and dbKey then dbTable[dbKey] = val end + + -- If editing a profile, also set the override (use effectiveOverrideKey) + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and effectiveOverrideKey then + DF.AutoProfilesUI:SetProfileSetting(effectiveOverrideKey, val) + end + + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + + if callback then + DF:Debug("GUI", "checkbox OnClick: calling callback") + callback() + end + if parent.RefreshStates then + DF:Debug("GUI", "checkbox OnClick: calling RefreshStates") + parent:RefreshStates() + end + DF:Debug("GUI", "checkbox OnClick: calling DF:UpdateAll") + DF:UpdateAll() + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget (box + check fill + label) so a disabled CHECKED + -- box greys too: native SetEnabled has no DisabledCheckedTexture, so the + -- accent check would otherwise stay full-bright. + self:SetAlpha(enabled and 1 or 0.4) + cb:SetEnabled(enabled) + if enabled then + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The + -- earlier hit-rect arithmetic here is gone with it — the hit frame is anchored + -- to the FontString, so a label overflowing the fixed 220 container is covered + -- for free rather than by widening the container's hit rect to match. + GUI:AttachTooltip(container, label, txt) + + UpdateState() + + -- SEARCH: Register this setting + if DF.Search then + local hasCustomGetSet = (customGet ~= nil or customSet ~= nil) + if dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterCheckbox(label, dbKey, nil, false, callback) + elseif hasCustomGetSet then + container.searchEntry = DF.Search:RegisterCheckbox(label, nil, nil, true, callback) + end + end + + return container +end + +-- ============================================================ +-- SEGMENT TOGGLE +-- A compact segmented control: the labels sit ON the buttons, all +-- of them boxed inside one recessed track so the pair reads as a +-- single control rather than two loose buttons. This is the +-- "Border Mode: [Shared][Custom]" idiom (AuraDesigner/Options.lua) +-- with the track added; use it for short mutually-exclusive values +-- that want to sit next to the field they qualify (s / %). +-- +-- API: GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) +-- segments : ordered { value =, label =, tooltip = } — label is what +-- shows on the button, tooltip the full name behind a terse one +-- opts.segmentWidth (26) / opts.height (18) +-- opts.fallbackValue : treated as selected when the stored value matches +-- no segment, so an unset key still lights the right button +-- opts.customGet / opts.customSet : same convention as CreateCheckbox / +-- CreateSlider / CreateDropdown — for a toggle over TRANSIENT UI +-- state (or one with its own save path) rather than a db key. Pass +-- dbTable/dbKey as nil when using these. +-- Returns the container with :Refresh(), :refreshContent() and :SetEnabled(). +-- ============================================================ +function GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) + opts = opts or {} + local segW = opts.segmentWidth or 26 + local h = opts.height or 18 + local pad = 1 -- track lip around the buttons + + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + container:SetSize(segW * #segments + pad * 2, h + pad * 2) + CreateElementBackdrop(container) -- the recessed track behind every segment + + -- One read/write pair for both pathways, so the click handler and Refresh + -- can't drift apart. Explicit ifs, not `a and b or c` — a stored value of + -- false/nil is legitimate. + local function GetValue() + if opts.customGet then return opts.customGet() end + if dbTable and dbKey then return dbTable[dbKey] end + end + local function SetValue(v) + if opts.customSet then opts.customSet(v) return true end + if dbTable and dbKey then dbTable[dbKey] = v return true end + return false + end + + local buttons = {} + for i, seg in ipairs(segments) do + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + GUI:StyleButton(btn, { width = segW, height = h, text = seg.label }) + GUI:SetSettingsFont(btn.Text, 9, "") + btn:SetPoint("TOPLEFT", container, "TOPLEFT", pad + (i - 1) * segW, -pad) + btn.value = seg.value + if seg.tooltip then + btn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = seg.tooltip, lines = opts.tooltipLines }) + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + btn:SetScript("OnClick", function(self) + if GetValue() == self.value then return end + if not SetValue(self.value) then return end + container:Refresh() + if callback then callback(self.value) end + end) + buttons[i] = btn + end + + -- Selection: the shared accent border/fill via SetActive, plus a bright/dim + -- label so the state still reads at a glance in a themed accent that is close + -- to the resting border colour. + function container:Refresh() + local cur = GetValue() + local matched = false + for _, b in ipairs(buttons) do if b.value == cur then matched = true end end + if not matched then cur = opts.fallbackValue end + for _, b in ipairs(buttons) do + local on = (b.value == cur) + b:SetActive(on) + if b.Text then + local c = on and C_TEXT or C_TEXT_DIM + b.Text:SetTextColor(c.r, c.g, c.b) + end + end + end + container.refreshContent = function(self) self:Refresh() end + + container.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + for _, b in ipairs(buttons) do b:EnableMouse(enabled) end + end + + container.UpdateTheme = function() container:Refresh() end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, container) + + container:Refresh() + return container +end + + +-- ============================================================ +-- DEBUG CATEGORY ROW +-- A wide row with checkbox + bold category name + description. +-- The whole row is clickable, hover shows a background highlight, +-- and the description is also surfaced as a tooltip on hover so it +-- remains accessible even if it gets visually truncated. +-- +-- Used by the Debug > Categories sub-tab. The categoryKey writes +-- directly to DandersFramesDB_v2.debug.filters. +-- ============================================================ +-- opts.noisy marks a firehose category: one user action can produce dozens of +-- lines, which evicts the trace the log was opened to capture. It renders as the +-- shared caution icon with its own tooltip rather than a "(noisy)" suffix baked +-- into the description string -- the suffix was untranslatable in place, and it +-- competed with the description for the same line of text. +function GUI:CreateDebugCategoryRow(parent, categoryKey, description, width, noisy) + local row = CreateFrame("Frame", nil, parent) + row:SetSize(width or 520, 28) + row:EnableMouse(true) + + -- Hover background + row.hoverBg = row:CreateTexture(nil, "BACKGROUND") + row.hoverBg:SetAllPoints() + row.hoverBg:SetColorTexture(1, 1, 1, 0.05) + row.hoverBg:Hide() + + -- Checkbox + local cb = CreateFrame("CheckButton", nil, row, "BackdropTemplate") + cb:SetPoint("LEFT", 4, 0) + GUI:StyleCheckButton(cb, { size = 16, checkSize = 9, themeRoot = parent }) + cb:EnableMouse(false) -- forward clicks to the row + + -- Category name (bold, full opacity) + local nameTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlight") + nameTxt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + nameTxt:SetWidth(86) + nameTxt:SetJustifyH("LEFT") + nameTxt:SetText(categoryKey) + nameTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Caution icon for a firehose category. Its own hit area, so it can carry a + -- different tooltip from the row without stealing the row's click: the frame + -- only enables mouse, it has no OnMouseUp, so a click still falls through to + -- the row underneath and toggles the category like anywhere else on it. + local noisyIcon + if noisy then + noisyIcon = CreateFrame("Frame", nil, row) + noisyIcon:SetSize(14, 14) + noisyIcon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + noisyIcon:EnableMouse(true) + noisyIcon:SetFrameLevel(row:GetFrameLevel() + 2) + local tex = noisyIcon:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + tex:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning") + -- The caution tone's ICON colour, read straight from the shared tone table + -- so this stays in step with every banner and note that uses it. + -- ☠ Read at CALL time, not through a file-scope alias. INFO_BANNER_TONES + -- is defined in GUI/Sections.lua, which lives in the load-on-demand + -- companion, while this file is resident -- an alias captured at load + -- would be nil forever and never see the companion's later publish. + -- This function is only ever called from a settings page, so the + -- companion is loaded by the time it runs. + local ic = GUI._priv.INFO_BANNER_TONES.caution.iconColor + tex:SetVertexColor(ic[1], ic[2], ic[3]) + noisyIcon:SetScript("OnEnter", function(self) + -- Keep the row's wash up: the pointer is still over the row, and + -- letting it drop would read as the row losing focus. + row.hoverBg:Show() + GUI:ShowTooltip(self, { + title = L["Noisy category"], + lines = { L["This category can fill the log very quickly, burying the entries you are looking for."], + L["Turn it on only while reproducing the bug it relates to."] }, + tone = "caution", + }) + end) + noisyIcon:SetScript("OnLeave", function() + row.hoverBg:Hide() + GUI:HideTooltip() + end) + row.noisyIcon = noisyIcon + end + + -- Description (dim, fills remaining space, wraps if too long) + if description and description ~= "" then + local descTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + descTxt:SetPoint("LEFT", nameTxt, "RIGHT", 12, 0) + -- Stop short of the icon rather than running under it. + if noisyIcon then + descTxt:SetPoint("RIGHT", noisyIcon, "LEFT", -6, 0) + else + descTxt:SetPoint("RIGHT", row, "RIGHT", -8, 0) + end + descTxt:SetJustifyH("LEFT") + descTxt:SetText(description) + descTxt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + row.descTxt = descTxt + end + + -- State helpers — read/write filters[categoryKey] + -- Absent or true = logged, explicit false = not logged + row.RefreshState = function() + local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters + local checked = (not filters) or filters[categoryKey] ~= false + cb:SetChecked(checked) + end + + local function ToggleState() + local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters + if not filters then return end + -- Toggle: false -> true, anything else -> false + if filters[categoryKey] == false then + filters[categoryKey] = true + else + filters[categoryKey] = false + end + row.RefreshState() + if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end + end + + row:SetScript("OnMouseUp", function(_, button) + if button == "LeftButton" then ToggleState() end + end) + + row:SetScript("OnEnter", function(self) + self.hoverBg:Show() + if description and description ~= "" then + GUI:ShowTooltip(self, { title = categoryKey, lines = { description } }) + end + end) + row:SetScript("OnLeave", function(self) + self.hoverBg:Hide() + GUI:HideTooltip() + end) + + row:SetScript("OnShow", row.RefreshState) + row.RefreshState() + + return row +end + +-- The one input "well": translucent-black fill + dim edge. Shared by StyleEditBox +-- (the single-line field) and CreateTextArea (the scrolling multi-line container) +-- so a text area and a text field read as the same control at two sizes. Passed +-- straight to CreateElementBackdrop, which only reads them. +local INPUT_FILL = { 0, 0, 0, 0.5 } +local INPUT_EDGE = { 0.3, 0.3, 0.3, 1 } + +-- StyleEditBox: normalize a bare (label-less) EditBox to the standard input +-- chrome used by CreateInput/CreateEditBox — translucent-black fill + dim border +-- + standard font/insets. The caller still owns size/position/scripts. Pass +-- opts.skipFont to keep a custom font (e.g. multi-line / monospace inputs). +function GUI:StyleEditBox(eb, opts) + opts = opts or {} + CreateElementBackdrop(eb, { + bgColor = INPUT_FILL, + borderColor = INPUT_EDGE, + }) + if not opts.skipFont then + eb:SetFontObject(DFFontHighlightSmall) + eb:SetTextInsets(5, 5, opts.multiline and 5 or 0, opts.multiline and 5 or 0) + end + -- Multiline mode: for text areas (export/import blobs, macro bodies). The + -- caller owns the ScrollFrame/sizing; this just flags the editbox + relaxes + -- the vertical insets. Enter inserts a newline (no auto clear-focus). + if opts.multiline then + eb:SetMultiLine(true) + eb:SetAutoFocus(false) + end + return eb +end + +function GUI:CreateInput(parent, label, width) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 180, 44) + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local editbox = CreateFrame("EditBox", nil, frame) + -- Two-corner anchored, so the offset and the height are the ONLY levers -- + -- Nothing corrects a frame's position at runtime, and controls are not + -- position-corrected at all any more. Snap both and all four edges land. + local ebY = SnapLen(editbox, -15) or -15 + editbox:SetPoint("TOPLEFT", 0, ebY) + editbox:SetPoint("TOPRIGHT", 0, ebY) + editbox:SetHeight(SnapLen(editbox, 24) or 24) + GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets + editbox:SetAutoFocus(false) + editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editbox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) + + -- Grey-when-disabled parity with CreateEditBox (cheap insurance if ever placed + -- in a gated group): dim the whole widget + block editing. + frame.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + editbox:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + frame.EditBox = editbox + -- Tooltip: shared attach on the LABEL only. This factory carried no tooltip + -- support at all, so a caller that set .tooltip on it got silence — + -- Options.lua's custom range-spell input did exactly that, and its + -- explanation never appeared. Keeping it off the edit box also means it can't + -- cover what you are typing. + GUI:AttachTooltip(frame, label, lbl) + return frame +end + +-- CreateEditBox: Text input with db binding (for settings like custom text) +function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 180, 44) + frame.preferredHeight = GUI.RowHeight.editbox -- factory-owned slot height (see GUI.RowHeight) + frame.rowKind = "editbox" + frame.fixedRowHeight = true + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + end + if frame.EditBox then + frame.EditBox:SetText(globalVal or "") + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(frame, lbl, dbKey, onReset, 6, nil, dbTable) + end + + local editbox = CreateFrame("EditBox", nil, frame) + -- Two-corner anchored, so the offset and the height are the ONLY levers -- + -- Nothing corrects a frame's position at runtime, and controls are not + -- position-corrected at all any more. Snap both and all four edges land. + local ebY = SnapLen(editbox, -15) or -15 + editbox:SetPoint("TOPLEFT", 0, ebY) + editbox:SetPoint("TOPRIGHT", 0, ebY) + editbox:SetHeight(SnapLen(editbox, 24) or 24) + GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets + editbox:SetAutoFocus(false) + + -- Set initial value from db + if dbTable and dbKey then + editbox:SetText(dbTable[dbKey] or "") + end + + -- Save on enter or focus lost + local function SaveValue() + if dbTable and dbKey then + local val = editbox:GetText() + -- Runtime override protection + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then + if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(val) end + return + end + dbTable[dbKey] = val + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + DF.AutoProfilesUI:SetProfileSetting(dbKey, val) + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(val) + end + if callback then callback() end + end + end + + editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editbox:SetScript("OnEnterPressed", function(self) + SaveValue() + self:ClearFocus() + end) + editbox:SetScript("OnEditFocusLost", SaveValue) + + -- Optional placeholder: greyed example text shown while the box is empty + -- and unfocused. Purely cosmetic — never written to the db. + if placeholder and placeholder ~= "" then + local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") + ph:SetPoint("LEFT", 5, 0) + ph:SetPoint("RIGHT", -5, 0) + ph:SetJustifyH("LEFT") + ph:SetText(placeholder) + ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) + local function UpdatePlaceholder() + ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") + end + -- Exposed so a caller that puts something INSIDE the box (see + -- GUI:AddEditBoxIcon) can move the placeholder clear of it. + editbox.Placeholder = ph + editbox.UpdatePlaceholder = UpdatePlaceholder + editbox:HookScript("OnTextChanged", UpdatePlaceholder) + editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) + editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) + UpdatePlaceholder() + end + + -- Refresh override indicators on show + frame:SetScript("OnShow", function() + if dbTable and dbKey then + editbox:SetText(dbTable[dbKey] or "") + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end + end) + + -- Grey-when-disabled: the grey loop (RefreshChildStates) calls widget:SetEnabled, + -- but this frame had none, so a disabled group left the input full-bright AND + -- editable. Dim the whole widget + block editing, matching the other helpers. + frame.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + editbox:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + frame.EditBox = editbox + return frame +end + +-- ============================================================ +-- LEADING ICON INSIDE AN EDIT BOX +-- The search-bar look from the main addon search (Features/Search.lua), made +-- available to any CreateEditBox rather than re-rolled per search field: the +-- glyph, the same 0.72 grey, and — the part that is easy to forget — the text +-- inset AND the placeholder both moved clear of it, so neither the typed text +-- nor the "Search..." hint runs underneath the icon. +-- +-- Pass frame.EditBox, not the frame. +-- ============================================================ +function GUI:AddEditBoxIcon(editbox, texture, size) + if not editbox or not texture then return end + size = size or 14 + local icon = editbox:CreateTexture(nil, "OVERLAY") + icon:SetSize(size, size) + icon:SetPoint("LEFT", 6, 0) + icon:SetTexture(texture) + icon:SetVertexColor(0.72, 0.72, 0.72) + editbox.Icon = icon + + local left = 6 + size + 5 + local _, right, top, bottom = editbox:GetTextInsets() + editbox:SetTextInsets(left, right, top, bottom) + if editbox.Placeholder then + editbox.Placeholder:SetPoint("LEFT", left, 0) + end + return icon +end + +-- ============================================================ +-- TEXT AREA — the multi-line cousin of CreateEditBox +-- A bordered well holding a scrolling multi-line EditBox. Eight surfaces built +-- this same container + ScrollFrame + EditBox stack by hand (export/import blobs, +-- the debug log viewer and script runner, the macro body, the popup's input mode, +-- the changelog), each picking its own well colour and three of them forgetting +-- the click-to-focus, so clicking the empty space below the text did nothing. +-- One owner, and the same well as every single-line input. +-- +-- opts: +-- width, height size the well; omit and anchor it yourself +-- text initial contents +-- fontObject default DFFontHighlightSmall +-- fontSize, fontFlags use the settings font at a size instead of a font object +-- maxLetters +-- readOnly show-and-copy (export strings, the changelog): user +-- edits bounce back, but it stays selectable + copyable +-- autoFocus take focus and select all on creation (copy-me popups) +-- onTextChanged(text, userInput) +-- onEscape(editBox) default: clear focus +-- bgColor, borderColor override the standard input well +-- plain skip the well entirely — for a text area that fills a +-- surface which already has its own panel (the changelog) +-- insets text insets, default 4 +-- Returns the well, with .EditBox / .ScrollFrame and SetText / GetText / +-- HighlightText / SetFocus / ClearFocus / SetEnabled forwarded to the field. +-- ============================================================ +local TEXTAREA_PAD = 4 -- gap between the well's edge and the scroll frame +local TEXTAREA_GUTTER = 18 -- room right of the scroll for the themed scrollbar + +function GUI:CreateTextArea(parent, opts) + opts = opts or {} + + local well = CreateFrame("Frame", nil, parent, "BackdropTemplate") + if opts.width and opts.height then well:SetSize(opts.width, opts.height) end + local pad = opts.plain and 0 or TEXTAREA_PAD + if not opts.plain then + CreateElementBackdrop(well, { + bgColor = opts.bgColor or INPUT_FILL, + borderColor = opts.borderColor or INPUT_EDGE, + }) + end + + local scroll = CreateFrame("ScrollFrame", nil, well, "ScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", pad, -pad) + scroll:SetPoint("BOTTOMRIGHT", -(pad + TEXTAREA_GUTTER), pad) + StyleScrollBar(scroll) + + local eb = CreateFrame("EditBox", nil, scroll) + eb:SetMultiLine(true) + eb:SetAutoFocus(false) + if opts.fontSize then + GUI:SetSettingsFont(eb, opts.fontSize, opts.fontFlags or "") + else + eb:SetFontObject(opts.fontObject or DFFontHighlightSmall) + end + eb:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local inset = opts.insets or (opts.plain and 0 or TEXTAREA_PAD) + eb:SetTextInsets(inset, inset, inset, inset) + if opts.maxLetters then eb:SetMaxLetters(opts.maxLetters) end + eb:SetScript("OnEscapePressed", opts.onEscape or function(s) s:ClearFocus() end) + scroll:SetScrollChild(eb) + + -- A scroll child has to be told its size, and that is only known once the + -- well has been sized or its anchors have resolved — which for an anchored + -- (rather than SetSize'd) well is not this frame. So do it here AND on every + -- resize, which is also what lets a text area sit in a resizable window + -- without the caller re-setting the width by hand on every show. + -- + -- Height is seeded ONCE and then left alone: after that the field owns it, + -- growing its own rect as text is added, which is what makes the scroll + -- frame scroll. Re-seeding on resize would clamp a grown field back down. + local heightSeeded = false + local function SyncSize(w, h) + if w and w > 0 then eb:SetWidth(w) end + if h and h > 0 and not heightSeeded then + heightSeeded = true + eb:SetHeight(h) + end + end + scroll:SetScript("OnSizeChanged", function(_, w, h) SyncSize(w, h) end) + SyncSize(scroll:GetWidth(), scroll:GetHeight()) + + -- Clicking anywhere in the well lands in the field, not just on the text + -- itself — the contents rarely fill the box. + well:EnableMouse(true) + well:SetScript("OnMouseDown", function() eb:SetFocus() end) + scroll:EnableMouse(true) + scroll:SetScript("OnMouseDown", function() eb:SetFocus() end) + + well.EditBox, well.ScrollFrame = eb, scroll + well.GetText = function(_) return eb:GetText() end + well.HighlightText = function(_, ...) eb:HighlightText(...) end + well.SetFocus = function(_) eb:SetFocus() end + well.ClearFocus = function(_) eb:ClearFocus() end + well.SetText = function(_, text) + eb:SetText(text or "") + eb:SetCursorPosition(0) -- long blobs open at the top, not the tail + end + + -- Grey-when-disabled, per the GUI conventions: dim the whole widget AND stop + -- it accepting edits. + well.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + eb:SetEnabled(enabled) + end + + -- WoW has no read-only EditBox. Bouncing the text back on any USER change + -- keeps Ctrl+A / Ctrl+C working, which EnableKeyboard(false) would not. + if opts.readOnly then + local locked = opts.text or "" + eb:SetScript("OnTextChanged", function(s, user) + if user and s:GetText() ~= locked then + s:SetText(locked) + s:HighlightText() + end + end) + well.SetText = function(_, text) + locked = text or "" + eb:SetText(locked) + eb:SetCursorPosition(0) + end + elseif opts.onTextChanged then + eb:SetScript("OnTextChanged", function(s, user) + opts.onTextChanged(s:GetText(), user) + end) + end + + if opts.text then well:SetText(opts.text) end + if opts.autoFocus then + eb:SetAutoFocus(true) + eb:SetFocus() + eb:HighlightText() + end + + return well +end + +-- customGet / customSet (optional, matches CreateDropdown's pattern): when +-- provided, the slider routes its reads and writes through these functions +-- instead of dbTable[dbKey] directly. Used by widgets whose underlying value +-- lives inside a nested table (e.g. Border Alpha → BorderColor.a), +-- where the plain `dbTable[dbKey] = v` path can't express the nesting. +-- Consumers that pass customSet typically pass dbKey = nil so the +-- auto-profile override system doesn't track a key that doesn't exist at the +-- top level of dbTable. +-- accentColor (optional {r,g,b}): fixed thumb/fill colour instead of the mode +-- theme — for ClickCasting (green) / Search (blue) which keep their identity. + +function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, callback, lightweightUpdate, usePreviewMode, customGet, customSet, accentColor) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 50) + container.preferredHeight = GUI.RowHeight.slider -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "slider" + container.fixedRowHeight = true + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided (for auto profiles) + -- Use vertical offset of 6 to align with label row (sliders have input box below) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + dbTable[dbKey] = globalVal + -- Update slider display + if container.slider then + container.slider:SetValue(globalVal) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, lbl, dbKey, onReset, 6, nil, dbTable) + end + + -- Background track. + -- Left edge and height here; the RIGHT edge is pinned to the value box further + -- down, once that exists. The track used to be a fixed 180px while a dropdown + -- anchors TOPLEFT+TOPRIGHT and fills its container, so on any panel wider than + -- the 260 default the two controls ended at visibly different x positions -- + -- and drifted further apart the wider the panel got. Both are container-driven + -- now, so they line up at any width instead of at one magic number. + local track = CreateFrame("Frame", nil, container, "BackdropTemplate") + track:SetPoint("TOPLEFT", 0, SnapLen(track, -18) or -18) + track:SetHeight(SnapLen(track, 8) or 8) + CreateElementBackdrop(track) + + -- Fill track (colored portion) + local fill = track:CreateTexture(nil, "ARTWORK") + fill:SetPoint("LEFT", 1, 0) + fill:SetHeight(6) + local c = accentColor or GetThemeColor() + fill:SetColorTexture(c.r, c.g, c.b, 0.8) + + -- Slider + local slider = CreateFrame("Slider", nil, container) + -- Same snapped offset/height as the track it sits on, or the invisible hit + -- area drifts off the visible bar by a fraction of a pixel. + slider:SetPoint("TOPLEFT", 0, SnapLen(slider, -18) or -18) + slider:SetHeight(SnapLen(slider, 8) or 8) -- right edge pinned to the value box, same as the track + slider:SetOrientation("HORIZONTAL") + slider:SetMinMaxValues(minVal, maxVal) + slider:SetValueStep(step) + slider:SetObeyStepOnDrag(true) + slider:SetHitRectInsets(-4, -4, -8, -8) + container.slider = slider -- Store reference for reset + + -- Track whether this slider is actively being dragged + local isDragging = false + + -- Store preview mode flag for this slider + local sliderUsePreviewMode = usePreviewMode or false + + -- Thumb + local thumb = slider:CreateTexture(nil, "OVERLAY") + thumb:SetSize(12, 16) + thumb:SetColorTexture(c.r, c.g, c.b, 1) + slider:SetThumbTexture(thumb) + + -- Value input + local input = CreateFrame("EditBox", nil, container, "BackdropTemplate") + input:SetSize(50, 20) + -- Pinned to the container's RIGHT edge -- the same edge a dropdown's opener + -- ends on -- and the track/slider then stretch from the left to meet it. + -- y = -12 keeps the 20px box centred on the 8px track at -18. + input:SetPoint("TOPRIGHT", 0, -12) + track:SetPoint("RIGHT", input, "LEFT", -8, 0) + slider:SetPoint("RIGHT", input, "LEFT", -8, 0) + CreateElementBackdrop(input) + input:SetFontObject(DFFontHighlightSmall) + input:SetJustifyH("CENTER") + input:SetAutoFocus(false) + input:SetTextInsets(2, 2, 0, 0) + + local function UpdateFill() + local val = slider:GetValue() + local pct = (val - minVal) / (maxVal - minVal) + -- Measured off the LIVE track, not the old hardcoded 178 (= the fixed 180 + -- track minus the fill's 1px inset each side). The track stretches now, so + -- a constant here would under-fill on any panel wider than the default. + local usable = (track:GetWidth() or 0) - 2 + if usable < 1 then usable = 1 end + fill:SetWidth(math.max(1, pct * usable)) + end + -- The track's width is only known once the page layout has resolved its + -- anchors, and changes again if the panel is resized -- so repaint the fill + -- whenever it does, or the bar renders at its pre-layout width. + track:SetScript("OnSizeChanged", function() UpdateFill() end) + + container.SetEnabled = function(self, enabled) + slider:SetEnabled(enabled) + -- Grey the numeric value box too: it was only EnableMouse'd (clicks blocked + -- but still full-bright + typeable), so it stayed lit while the track dimmed. + input:EnableMouse(enabled) + input:SetEnabled(enabled) + input:SetAlpha(enabled and 1 or 0.4) + local tc = accentColor or GetThemeColor() + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + thumb:SetColorTexture(tc.r, tc.g, tc.b, 1) + fill:SetColorTexture(tc.r, tc.g, tc.b, 0.8) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + thumb:SetColorTexture(0.4, 0.4, 0.4, 1) + fill:SetColorTexture(0.4, 0.4, 0.4, 0.5) + end + end + + container.UpdateTheme = function() + local nc = accentColor or GetThemeColor() + if slider:IsEnabled() then + thumb:SetColorTexture(nc.r, nc.g, nc.b, 1) + fill:SetColorTexture(nc.r, nc.g, nc.b, 0.8) + end + end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, container) + + local suppressCallback = false + + -- Smart format: show whole numbers as integers, decimals with minimum precision needed + local function FormatValue(val) + if val == math.floor(val) then + return string.format("%d", val) + elseif val * 10 == math.floor(val * 10) then + return string.format("%.1f", val) + else + return string.format("%.2f", val) + end + end + + -- Wrapper for both pathways: customGet/Set when provided, dbTable[dbKey] + -- otherwise. Centralising this avoids a sprinkling of `if customGet then` + -- across every place the slider touches its value. + local function ReadValue() + if customGet then return customGet() end + if dbTable then return dbTable[dbKey] end + return nil + end + local function WriteValue(v) + if customSet then return customSet(v) end + if dbTable then dbTable[dbKey] = v end + end + + local function UpdateValue(val) + val = val or minVal + suppressCallback = true + slider:SetValue(val) + suppressCallback = false + input:SetText(FormatValue(val)) + UpdateFill() + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + + -- Re-read the bound value (customGet or dbTable[dbKey]) and redraw. For a slider whose + -- source can change under it — e.g. one bound through customGet to whichever key an + -- account-wide unit dial currently selects — called from refreshContent. + container.RefreshValue = function(self) + local v = ReadValue() + if v ~= nil then UpdateValue(math.max(minVal, math.min(maxVal, v))) end + end + -- Runtime range. A slider whose UNIT is decided elsewhere (seconds vs percent) is built + -- once but must re-scale in place, or flipping that dial leaves a 1-60 track in front of + -- a percentage until the panel is rebuilt. Pair with container.label for the caption. + container.SetRange = function(self, newMin, newMax) + if newMin ~= minVal or newMax ~= maxVal then + minVal, maxVal = newMin, newMax + slider:SetMinMaxValues(minVal, maxVal) + end + self:RefreshValue() + end + + -- Track drag start - pass the lightweight update function, name for debug, and preview mode + slider:SetScript("OnMouseDown", function(self, button) + if button == "LeftButton" then + isDragging = true + local funcName = lightweightUpdate and ((dbKey or label or "slider") .. " lightweight") or nil + DF:OnSliderDragStart(lightweightUpdate, funcName, sliderUsePreviewMode) + end + end) + + -- Track drag end - do full update when slider is released + slider:SetScript("OnMouseUp", function(self, button) + if button == "LeftButton" and isDragging then + isDragging = false + DF:OnSliderDragStop() + -- Update override indicators after drag ends + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(slider:GetValue()) + end + end + end) + + slider:SetScript("OnShow", function() + local v = ReadValue() + if v ~= nil then UpdateValue(v) end + end) + + slider:SetScript("OnValueChanged", function(self, value) + if suppressCallback then return end + if not (dbTable or customSet) then return end + if step >= 1 then + value = math.floor(value + 0.5) + else + value = math.floor(value / step + 0.5) * step + end + + -- Runtime override protection: redirect to baseline, skip refresh + if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, value) then + if not input:HasFocus() then input:SetText(FormatValue(value)) end + UpdateFill() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(value) end + return + end + + WriteValue(value) + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, value) + end + + if not input:HasFocus() then + input:SetText(FormatValue(value)) + end + UpdateFill() + -- Use targeted update system - lightweight during drag, full on release + DF:ThrottledUpdateAll() + -- Skip callback during drag - it will run via UpdateAll on release + if callback and not DF.sliderDragging then + callback() + end + end) + + input:SetScript("OnEnterPressed", function(self) + local val = tonumber(self:GetText()) + if val then + val = math.max(minVal, math.min(maxVal, val)) + + -- Runtime override protection: redirect to baseline, skip refresh + if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then + self:SetText(FormatValue(val)) + suppressCallback = true + slider:SetValue(val) + suppressCallback = false + UpdateFill() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end + self:ClearFocus() + return + end + + WriteValue(val) + suppressCallback = true + slider:SetValue(val) + suppressCallback = false + + -- Update input text to show actual value entered + self:SetText(FormatValue(val)) + UpdateFill() + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, val) + end + + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + + -- FIX 2025-01-20: Call callback OR lightweightUpdate (some sliders have nil callback) + if callback then + callback() + elseif lightweightUpdate then + lightweightUpdate() + end + + -- Guaranteed full update (SetValue may not fire OnValueChanged if value didn't change) + DF:UpdateAll() + else + local v = ReadValue(); if v ~= nil then UpdateValue(v) end + end + self:ClearFocus() + end) + + input:SetScript("OnEscapePressed", function(self) + local v = ReadValue(); if v ~= nil then UpdateValue(v) end + self:ClearFocus() + end) + + local initial = ReadValue() + if initial ~= nil then UpdateValue(initial) end + + -- SEARCH: Register this setting with slider metadata + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterSlider(label, dbKey, minVal, maxVal, step, nil, callback) + end + + -- Expose label for dynamic updates + container.label = lbl + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). Keeping it + -- off the bar matters most here — a tooltip over a slider you are dragging is + -- the worst case of the problem. Both .tooltip (title from the label) and the + -- legacy .tooltipText/.tooltipSubText pair are honoured. + GUI:AttachTooltip(container, label, lbl) + + return container +end + +-- Stamp the shared Frame Level explanation onto a slider. One helper rather than the same +-- two strings at 21 call sites, and it keeps the wording in ONE place -- the old per-page +-- label went stale the moment the scale changed (it still read "0=Auto" afterwards). +-- Takes the CONTAINER that CreateSlider returns, which is what every call site has. +function GUI:SetFrameLevelTooltip(container) + if not container then return end + container.tooltipText = L["Frame Level"] + container.tooltipSubText = L["Higher numbers draw on top of lower ones. Every Frame Level in DandersFrames uses the same scale, counted up from the unit frame, so you can compare them directly."] + return container -- chainable, so it wraps a CreateSlider call in place +end + +-- Dual-handle range slider: two draggable handles select a [lo, hi] sub-range of +-- [minRange, maxRange]. Self-contained — the caller anchors the returned track +-- frame and reads values via the onChange callback. (:GetValues() also exists and +-- completes the SetValues pair, but no current consumer polls it.) Drag is +-- tracked on the track's own OnUpdate (no dependence on parent scripts), and a +-- mouse-button check releases the drag even if the cursor leaves the handle. +-- opts: +-- width(336), accent({r,g,b}=theme), minRange, maxRange, lo, hi, +-- scaleLabels({...} optional tick labels), scaleMin/scaleMax (label scale, +-- default minRange/maxRange — lets ticks stay on a fixed scale while the +-- handle range changes), display(FontString updated each change), +-- formatRange(fn(lo,hi)->str), formatOne(fn(v)->str), +-- onChange(fn(lo,hi) — fired on user-driven changes only, not SetValues). +-- Methods on the returned frame: :SetRange(min,max), :SetValues(lo,hi), +-- :GetValues()->lo,hi. +function GUI:CreateRangeSlider(parent, opts) + opts = opts or {} + local width = opts.width or 336 + local accent = opts.accent or GetThemeColor() + + local track = CreateFrame("Frame", nil, parent, "BackdropTemplate") + track:SetSize(width, 12) + CreateElementBackdrop(track, { + bgColor = { 0.03, 0.03, 0.03, 1 }, + borderColor = { 0.2, 0.2, 0.2, 1 }, + }) + + track.minRange = opts.minRange or 1 + track.maxRange = opts.maxRange or 40 + track.lo = opts.lo or track.minRange + track.hi = opts.hi or track.maxRange + + local rangeFill = track:CreateTexture(nil, "ARTWORK") + rangeFill:SetTexture("Interface\\Buttons\\WHITE8x8") + rangeFill:SetVertexColor(accent.r, accent.g, accent.b, 0.5) + rangeFill:SetHeight(10) + rangeFill:SetPoint("TOP", 0, -1) + + local function MakeHandle() + local h = CreateFrame("Button", nil, track) + h:SetSize(8, 16) + h:EnableMouse(true) + local tex = h:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + tex:SetTexture("Interface\\Buttons\\WHITE8x8") + tex:SetVertexColor(accent.r, accent.g, accent.b, 1) + return h + end + local minHandle, maxHandle = MakeHandle(), MakeHandle() + track.minHandle, track.maxHandle = minHandle, maxHandle + + local function ValueToPos(value) + local pct = (value - track.minRange) / (track.maxRange - track.minRange) + return pct * (width - 4) + 2 + end + local function PosToValue(pos) + local pct = (pos - 2) / (width - 4) + return math.floor(pct * (track.maxRange - track.minRange) + track.minRange + 0.5) + end + + local function Redraw() + local minPos, maxPos = ValueToPos(track.lo), ValueToPos(track.hi) + minHandle:ClearAllPoints() + minHandle:SetPoint("CENTER", track, "LEFT", minPos, 0) + maxHandle:ClearAllPoints() + maxHandle:SetPoint("CENTER", track, "LEFT", maxPos, 0) + rangeFill:ClearAllPoints() + rangeFill:SetPoint("LEFT", track, "LEFT", minPos, 0) + rangeFill:SetWidth(math.max(maxPos - minPos, 2)) + if opts.display then + if track.lo == track.hi then + opts.display:SetText(opts.formatOne and opts.formatOne(track.lo) or tostring(track.lo)) + else + opts.display:SetText(opts.formatRange and opts.formatRange(track.lo, track.hi) + or (track.lo .. " - " .. track.hi)) + end + end + end + + local dragging = nil + local function ApplyCursor() + local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() + local trackLeft = track:GetLeft() + if not trackLeft then return end + local pos = math.max(2, math.min(x - trackLeft, width - 2)) + local value = math.max(track.minRange, math.min(PosToValue(pos), track.maxRange)) + if dragging == "min" then + if value <= track.hi then track.lo = value end + elseif dragging == "max" then + if value >= track.lo then track.hi = value end + end + Redraw() + if opts.onChange then opts.onChange(track.lo, track.hi) end + end + + minHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "min" end end) + maxHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "max" end end) + track:SetScript("OnUpdate", function() + if not dragging then return end + if not IsMouseButtonDown("LeftButton") then dragging = nil; return end + ApplyCursor() + end) + + track:EnableMouse(true) + track:SetScript("OnMouseDown", function(_, b) + if b ~= "LeftButton" then return end + local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() + local trackLeft = track:GetLeft() + if not trackLeft then return end + local value = PosToValue(x - trackLeft) + if math.abs(value - track.lo) <= math.abs(value - track.hi) then + if value <= track.hi then track.lo = math.max(track.minRange, value) end + else + if value >= track.lo then track.hi = math.min(track.maxRange, value) end + end + Redraw() + if opts.onChange then opts.onChange(track.lo, track.hi) end + end) + + if opts.scaleLabels then + local sMin = opts.scaleMin or track.minRange + local sMax = opts.scaleMax or track.maxRange + for _, num in ipairs(opts.scaleLabels) do + local pct = (num - sMin) / (sMax - sMin) + local xPos = pct * (width - 4) + 2 + local lbl = track:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetText(num) + lbl:SetTextColor(0.35, 0.35, 0.35) + lbl:SetPoint("TOP", track, "BOTTOM", xPos - width / 2, -2) + end + end + + function track:SetRange(minR, maxR) + self.minRange, self.maxRange = minR, maxR + self.lo = math.max(minR, math.min(self.lo, maxR)) + self.hi = math.max(minR, math.min(self.hi, maxR)) + Redraw() + end + function track:SetValues(lo, hi) + self.lo = math.max(self.minRange, math.min(lo, self.maxRange)) + self.hi = math.max(self.minRange, math.min(hi, self.maxRange)) + Redraw() + end + function track:GetValues() return self.lo, self.hi end + + Redraw() + return track +end + +function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback, lightweightCallback, useLightweight) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 28) + container.preferredHeight = GUI.RowHeight.colorpicker -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "colorpicker" + container.fixedRowHeight = true + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, 0) + btn:SetPoint("TOPRIGHT", 0, 0) + btn:SetHeight(SnapLen(btn, 24) or 24) + CreateElementBackdrop(btn) + + -- Label + local txt = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", 8, 0) + txt:SetText(label) + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Color swatch + local swatch = btn:CreateTexture(nil, "OVERLAY") + swatch:SetSize(40, 16) + swatch:SetPoint("RIGHT", -6, 0) + + -- Add override indicators if dbKey is provided (for auto profiles) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if globalVal then + dbTable[dbKey].r = globalVal.r + dbTable[dbKey].g = globalVal.g + dbTable[dbKey].b = globalVal.b + dbTable[dbKey].a = globalVal.a or 1 + end + if container.UpdateSwatch then + container:UpdateSwatch() + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable[dbKey]) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, txt, dbKey, onReset, nil, nil, dbTable) + end + + local function UpdateSwatch() + if dbTable and dbKey and dbTable[dbKey] then + local c = dbTable[dbKey] + swatch:SetColorTexture(c.r, c.g, c.b, c.a or 1) + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(c) + end + end + end + container.UpdateSwatch = UpdateSwatch -- Expose for reset + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + -- Tooltip: shared attach on the LABEL only. This factory carried none, across + -- 87 colour pickers. The btn keeps its own hover scripts untouched — the hit + -- frame is over the text, not the swatch. + GUI:AttachTooltip(container, label, txt) + + btn:SetScript("OnClick", function() + if not dbTable then return end + local c = dbTable[dbKey] + if not c then + c = {r = 1, g = 1, b = 1, a = 1} + dbTable[dbKey] = c + end + + -- Store original values for cancel + local originalColor = {r = c.r, g = c.g, b = c.b, a = c.a or 1} + + -- Blizzard's SetupColorPickerAndShow fires swatchFunc once DURING + -- setup (its SetColorRGB triggers OnColorSelect — the source comments + -- it). That spurious fire re-writes the unchanged colour and runs the + -- change callbacks on mere open: it commits per-element override flags + -- (Text Designer) and triggers a pointless full refresh. Suppress + -- callbacks until setup has returned. + local settingUp = true + + local info = { + swatchFunc = function() + if settingUp then return end + local r, g, b = ColorPickerFrame:GetColorRGB() + local a = 1 + if hasAlpha and ColorPickerFrame.GetColorAlpha then + a = ColorPickerFrame:GetColorAlpha() or 1 + end + dbTable[dbKey].r = r + dbTable[dbKey].g = g + dbTable[dbKey].b = b + dbTable[dbKey].a = a + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, {r = r, g = g, b = b, a = a}) + end + + UpdateSwatch() + -- Use lightweight callback during dragging if available + if useLightweight and lightweightCallback then + lightweightCallback() + else + DF:ThrottledUpdateAll() + if callback then callback() end + end + end, + hasOpacity = hasAlpha, + opacityFunc = hasAlpha and function() + if settingUp then return end + if ColorPickerFrame.GetColorAlpha then + local a = ColorPickerFrame:GetColorAlpha() + if a then + dbTable[dbKey].a = a + UpdateSwatch() + -- Use lightweight callback during dragging if available + if useLightweight and lightweightCallback then + lightweightCallback() + else + DF:ThrottledUpdateAll() + if callback then callback() end + end + end + end + end or nil, + cancelFunc = function(restore) + -- Restore original color on cancel + dbTable[dbKey].r = originalColor.r + dbTable[dbKey].g = originalColor.g + dbTable[dbKey].b = originalColor.b + dbTable[dbKey].a = originalColor.a + UpdateSwatch() + DF:UpdateAll() + if callback then callback() end + end, + r = c.r or 1, + g = c.g or 1, + b = c.b or 1, + opacity = hasAlpha and (c.a or 1) or nil, + } + + -- Hook the OK button to run full update when confirmed + if useLightweight and lightweightCallback then + -- We need to run full update when picker is closed via OK + -- Use a frame to detect when color picker closes + if not container.colorPickerWatcher then + container.colorPickerWatcher = CreateFrame("Frame") + end + container.colorPickerWatcher:SetScript("OnUpdate", function(self) + if not ColorPickerFrame:IsShown() then + self:SetScript("OnUpdate", nil) + -- Only run if color changed (not cancelled) + local cur = dbTable[dbKey] + if cur.r ~= originalColor.r or cur.g ~= originalColor.g or + cur.b ~= originalColor.b or cur.a ~= originalColor.a then + DF:UpdateAll() + if callback then callback() end + end + end + end) + end + + -- Attach default colour so the picker can offer a Default button + -- dbTable.__dfDefaults is set by callers (e.g. Aura Designer proxies) that + -- store their defaults outside DF.PartyDefaults / DF.RaidDefaults. Read via + -- rawget so proxies' __index doesn't see this lookup as a regular setting. + local defaultVal = (dbTable and rawget(dbTable, "__dfDefaults") and dbTable.__dfDefaults[dbKey]) + or (DF.PartyDefaults and DF.PartyDefaults[dbKey]) + or (DF.RaidDefaults and DF.RaidDefaults[dbKey]) + -- Fallback: power bar colours use WoW's PowerBarColor table as their default + if not defaultVal and PowerBarColor and dbKey then + defaultVal = PowerBarColor[dbKey] + end + if defaultVal and type(defaultVal) == "table" and defaultVal.r then + info.dfDefaultColor = {r = defaultVal.r or 1, g = defaultVal.g or 1, b = defaultVal.b or 1, a = defaultVal.a or 1} + -- Populate ElvUI's "Default" button (ColorPPDefault) so it enables and + -- pastes the DF setting default when the native Blizzard picker is shown + local elvDefault = _G["ColorPPDefault"] + if elvDefault then + elvDefault.colors = info.dfDefaultColor + end + end + + -- Mark this as a DandersFrames color picker call + GUI:MarkColorPickerCall() + ColorPickerFrame:SetupColorPickerAndShow(info) + settingUp = false + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so the colour swatch greys even when it's a dark + -- colour (SetDesaturated alone is invisible on near-black swatches). + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + swatch:SetDesaturated(false) + else + txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + swatch:SetDesaturated(true) + end + end + + btn:SetScript("OnShow", UpdateSwatch) + UpdateSwatch() + + -- SEARCH: Register this setting + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterColorPicker(label, dbKey, hasAlpha, nil, callback) + end + + return container +end + +-- ============================================================ +-- OPEN-MENU REGISTRY +-- Dropdown/preset menus anchor to their button and survive context switches +-- that leave the button visible (e.g. the Aura Designer changing tabs). +-- Every menu frame registers here at creation; CloseAllMenus() lets a +-- context switch dismiss whatever is open. +-- ============================================================ +GUI._menus = GUI._menus or {} +function GUI:RegisterMenu(frame) self._menus[frame] = true end +function GUI:CloseAllMenus() + for f in pairs(self._menus) do + if f:IsShown() then f:Hide() end + end +end + +function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, customGet, customSet, opts) + opts = opts or {} + local accentColor = opts.accent + local optionsFunc = opts.optionsFunc + + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, opts.inline and 24 or 50) + -- Inline dropdowns embed in a caller-managed layout (label hidden), so only the standalone + -- form owns a fixed slot height; inline keeps whatever height its host passes. + if not opts.inline then + container.preferredHeight = GUI.RowHeight.dropdown -- factory-owned slot (see GUI.RowHeight) + container.rowKind = "dropdown" + container.fixedRowHeight = true + end + + -- Label + local lbl = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + -- Expose label so helpers like AddSectionNewBadge can anchor a badge to it. + container.label = lbl + if opts.inline then + lbl:Hide() + end + + -- Add override indicators if dbKey is provided (for auto profiles) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + dbTable[dbKey] = globalVal + if container.UpdateText then + container:UpdateText() + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, lbl, dbKey, onReset, 6, options, dbTable) + end + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + if opts.inline then + -- Fill the container so the caller's SetSize(w, h) controls the opener + -- size + vertical centering (inline callers add their own left label and + -- size the container to match the surrounding row, e.g. 140x18 / 110x16). + btn:SetAllPoints(container) + else + local dY = SnapLen(btn, -16) or -16 + btn:SetPoint("TOPLEFT", 0, dY) + btn:SetPoint("TOPRIGHT", 0, dY) + btn:SetHeight(SnapLen(btn, 24) or 24) + end + CreateElementBackdrop(btn) + + btn.Text = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + btn.Text:SetPoint("LEFT", 8, 0) + btn.Text:SetPoint("RIGHT", -20, 0) + btn.Text:SetJustifyH("LEFT") + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Arrow indicator + local arrow = btn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -8, 0) + arrow:SetSize(12, 12) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- SetDisplayOverride: a fixed opener caption that wins over the selected + -- option's text (e.g. a disabled dropdown explaining WHY it's disabled, + -- like the Aura Designer spec dropdown's "shared across specs" state). + -- nil clears the override and restores the selected option's text. + local displayOverride + local function UpdateText() + if displayOverride then + btn.Text:SetText(displayOverride) + return + end + if customGet or (dbTable and dbKey) then + local val = customGet and customGet() or dbTable[dbKey] + local displayVal = options[val] + -- Handle table format: {value = X, text = "text"} or {text = "text"} + local optColor + if type(displayVal) == "table" then + optColor = displayVal.color + displayVal = displayVal.text or displayVal.label or tostring(val) + end + btn.Text:SetText(displayVal or tostring(val) or L["Select..."]) + -- Selected label mirrors its option row's colour when the option + -- carries one (e.g. class-coloured specs); plain options reset to + -- the standard text colour. Skipped while disabled so the + -- grey-when-disabled treatment from SetEnabled stays intact. + if btn:IsEnabled() then + local tc = optColor or C_TEXT + btn.Text:SetTextColor(tc.r, tc.g, tc.b) + end + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + end + container.UpdateText = UpdateText -- Expose for reset + container.SetDisplayOverride = function(self, text) + displayOverride = text + UpdateText() + end + + -- Menu frame + -- Menus hang from the opener's LEFT edge by default, so a wider-than-opener + -- menu spills rightward. opts.menuAlign = "RIGHT" pins the menu's TOPRIGHT + -- to the opener's BOTTOMRIGHT instead (surplus width grows leftward) — for + -- openers sitting near a right edge, e.g. the Aura Designer spec dropdown. + local menuFrame = CreateFrame("Frame", nil, btn, "BackdropTemplate") + if opts.menuAlign == "RIGHT" then + menuFrame:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT", 0, -2) + else + menuFrame:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2) + end + menuFrame:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menuFrame) + menuFrame:SetClampedToScreen(true) + CreateElementBackdrop(menuFrame) + menuFrame:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.98) + menuFrame:Hide() + + -- Searchable menus (opts.searchable): a search box pinned above a scrollable + -- item list, mirroring the font/sound dropdowns. Option sets may also carry + -- non-clickable group-header rows — `_order` entries whose option value is + -- { header = true, text = ..., color = ... } (e.g. class-coloured spec + -- groups). While filtering, a header only stays visible if at least one of + -- its options matches. + local searchable = opts.searchable + local ITEM_HEIGHT = 22 + local SEARCH_HEIGHT = 26 + local MAX_VISIBLE = opts.maxVisible or 12 + local searchBox, scrollFrame, scrollChild, searchPlaceholder + if searchable then + searchBox = CreateFrame("EditBox", nil, menuFrame, "BackdropTemplate") + searchBox:SetPoint("TOPLEFT", 4, -4) + searchBox:SetPoint("TOPRIGHT", -4, -4) + searchBox:SetHeight(22) + searchBox:SetAutoFocus(false) + searchBox:SetFontObject(DFFontHighlightSmall) + searchBox:SetTextInsets(24, 8, 0, 0) + CreateElementBackdrop(searchBox) + searchBox:SetBackdropColor(0.1, 0.1, 0.1, 1) + + local searchIcon = searchBox:CreateTexture(nil, "OVERLAY") + searchIcon:SetPoint("LEFT", 6, 0) + searchIcon:SetSize(12, 12) + searchIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\search") + searchIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + searchPlaceholder:SetPoint("LEFT", 24, 0) + searchPlaceholder:SetText(L["Search..."]) + searchPlaceholder:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.6) + + searchBox:SetScript("OnEditFocusGained", function() searchPlaceholder:Hide() end) + searchBox:SetScript("OnEditFocusLost", function() + if searchBox:GetText() == "" then searchPlaceholder:Show() end + end) + searchBox:SetScript("OnEscapePressed", function() menuFrame:Hide() end) + + scrollFrame = CreateFrame("ScrollFrame", nil, menuFrame, "ScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", 2, -(SEARCH_HEIGHT + 4)) + scrollFrame:SetPoint("BOTTOMRIGHT", -20, 2) + scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetWidth(200) -- resized to the menu width on each rebuild + scrollFrame:SetScrollChild(scrollChild) + StyleScrollBar(scrollFrame) + end + + -- Clear tracking when hidden + menuFrame:SetScript("OnHide", function() + if S.currentOpenDropdown == menuFrame then + S.currentOpenDropdown = nil + end + if searchBox then + searchBox:SetText("") + searchBox:ClearFocus() + searchPlaceholder:Show() + end + end) + + local menuButtons = {} + local menuHeight = 0 + local sortedOptions = {} + + -- Build (or rebuild) the menu buttons from the current `options` upvalue. + -- Rows are POOLED (frames can't be garbage-collected) — rebuilds reuse + -- existing buttons and hide the surplus, so dynamic/searchable dropdowns + -- don't leak a row set per rebuild. Static callers build exactly once below. + local menuContentW = 0 -- widest item text; sizes the menu to fit long options + local function BuildMenuButtons(filterText) + for _, b in ipairs(menuButtons) do b:Hide() end + wipe(sortedOptions) + menuHeight = 0 + + -- Collect the ordered option list (header rows ride along) + local ordered = {} + -- Check for custom order array + if options._order then + -- Use specified order + for _, k in ipairs(options._order) do + local v = options[k] + if v then + -- Handle both formats: KEY = "text" or KEY = {value=, text=, color=, header=} + local isTable = type(v) == "table" + table.insert(ordered, { + key = k, + value = isTable and (v.text or v.label or tostring(k)) or v, + color = isTable and v.color or nil, + header = isTable and v.header or nil, + }) + end + end + else + -- Default: sort alphabetically by display value + for k, v in pairs(options) do + local isTable = type(v) == "table" + table.insert(ordered, { + key = k, + value = isTable and (v.text or v.label or tostring(k)) or v, + color = isTable and v.color or nil, + header = isTable and v.header or nil, + }) + end + table.sort(ordered, function(a, b) + local aVal = type(a.value) == "string" and a.value or tostring(a.key) + local bVal = type(b.value) == "string" and b.value or tostring(b.key) + return aVal < bVal + end) + end + + -- Apply the search filter (searchable menus): match option display text; + -- keep a group header only when one of its options survives. + local filter = filterText and filterText ~= "" and filterText:lower() or nil + if filter then + local pendingHeader + for _, opt in ipairs(ordered) do + if opt.header then + pendingHeader = opt + else + local txt = type(opt.value) == "string" and opt.value:lower() or tostring(opt.key):lower() + if txt:find(filter, 1, true) then + if pendingHeader then + table.insert(sortedOptions, pendingHeader) + pendingHeader = nil + end + table.insert(sortedOptions, opt) + end + end + end + else + for _, opt in ipairs(ordered) do + table.insert(sortedOptions, opt) + end + end + + local itemParent = scrollChild or menuFrame + local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) + local selColor = accentColor or GetThemeColor() + -- Once a group header has appeared, subsequent option rows indent under + -- it (menus without headers keep the flat 8px inset). + local seenHeader = false + + for i, opt in ipairs(sortedOptions) do + local menuBtn = menuButtons[i] + if not menuBtn then + menuBtn = CreateFrame("Button", nil, itemParent) + menuBtn:SetPoint("TOPLEFT", 2, -2 - (i - 1) * ITEM_HEIGHT) + menuBtn:SetPoint("TOPRIGHT", -2, -2 - (i - 1) * ITEM_HEIGHT) + menuBtn:SetHeight(ITEM_HEIGHT) + + menuBtn.Text = menuBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + menuBtn.Text:SetPoint("LEFT", 8, 0) + + -- Subtle separator above group-header rows (hidden on option rows + -- and on a header that is the first visible row) + menuBtn.Sep = menuBtn:CreateTexture(nil, "ARTWORK") + menuBtn.Sep:SetHeight(1) + menuBtn.Sep:SetPoint("TOPLEFT", 4, 1) + menuBtn.Sep:SetPoint("TOPRIGHT", -4, 1) + menuBtn.Sep:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.6) + menuBtn.Sep:Hide() + + menuBtn.Highlight = menuBtn:CreateTexture(nil, "HIGHLIGHT") + menuBtn.Highlight:SetAllPoints() + + menuBtn:SetScript("OnClick", function(self) + local optKey = self.optKey + -- Runtime override protection: redirect to baseline, skip refresh + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, optKey) then + UpdateText() + menuFrame:Hide() + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(optKey) end + return + end + + if customSet then + customSet(optKey) + else + dbTable[dbKey] = optKey + end + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, customGet and customGet() or optKey) + end + + UpdateText() + menuFrame:Hide() + DF:UpdateAll() + if callback then callback() end + if parent.RefreshStates then parent:RefreshStates() end + end) + + menuButtons[i] = menuBtn + end + + menuBtn.optKey = opt.key + menuBtn.Text:SetText(opt.value) + menuBtn.Highlight:SetColorTexture(selColor.r, selColor.g, selColor.b, 0.3) + if opt.header then + -- Non-clickable group label (no mouse ⇒ no hover highlight). + -- Heading treatment: small uppercase label in the group colour, + -- separator line above (except when it's the first row), and + -- the option rows beneath it are indented — so headers read as + -- section labels rather than selectable entries. + menuBtn:EnableMouse(false) + local hc = opt.color or C_TEXT_DIM + menuBtn.Text:SetTextColor(hc.r, hc.g, hc.b) + -- SetTextScale (not SetSettingsFont) so the pooled row resets + -- cleanly when it's reused as a regular option row. + menuBtn.Text:SetTextScale(0.85) + if type(opt.value) == "string" then + menuBtn.Text:SetText(opt.value:upper()) + end + menuBtn.Text:ClearAllPoints() + menuBtn.Text:SetPoint("LEFT", 8, 0) + menuBtn.Sep:SetShown(i > 1) + seenHeader = true + else + menuBtn.Text:SetTextScale(1) + menuBtn.Text:ClearAllPoints() + menuBtn.Text:SetPoint("LEFT", seenHeader and 16 or 8, 0) + menuBtn.Sep:Hide() + menuBtn:EnableMouse(true) + if opt.color then + -- per-option colour (e.g. class-coloured spec list) always wins + menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) + elseif currentVal == opt.key then + menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + menuBtn:Show() + menuHeight = menuHeight + ITEM_HEIGHT + end + + -- Width fits the widest item so long options aren't clipped by a narrow + -- opener (refined to max(opener, content) on open, once btn is sized). + menuContentW = 0 + for i = 1, #sortedOptions do + menuContentW = math.max(menuContentW, menuButtons[i].Text:GetStringWidth() or 0) + end + if searchable then + local visible = math.min(#sortedOptions, MAX_VISIBLE) + menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) + menuFrame:SetHeight(visible * ITEM_HEIGHT + SEARCH_HEIGHT + 8) + scrollChild:SetWidth(menuFrame:GetWidth() - 24) + scrollChild:SetHeight(menuHeight + 4) + scrollFrame:SetVerticalScroll(0) + else + menuFrame:SetWidth(menuContentW + 24) + menuFrame:SetHeight(menuHeight + 4) + end + end + + BuildMenuButtons() + + if searchBox then + searchBox:SetScript("OnTextChanged", function(self) + if menuFrame:IsShown() then BuildMenuButtons(self:GetText()) end + end) + end + + -- Allow dynamic dropdowns to swap their option set and regenerate buttons. + container.RebuildOptions = function(_, newOptions) + if newOptions then options = newOptions end + BuildMenuButtons(searchBox and searchBox:GetText() or nil) + UpdateText() + end + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + btn:SetScript("OnClick", function(self) + if menuFrame:IsShown() then + menuFrame:Hide() + S.currentOpenDropdown = nil + else + -- Close any other open dropdown first + CloseOpenDropdown() + -- Dynamic dropdowns regenerate their option list each open. + if optionsFunc then + container:RebuildOptions(optionsFunc()) + elseif searchable then + -- Searchable menus reopen unfiltered (search cleared on hide) + BuildMenuButtons() + else + -- Static menus: refresh selected-value colouring on the pooled rows + local currentVal = customGet and customGet() or (dbTable and dbKey and dbTable[dbKey]) + local selColor = accentColor or GetThemeColor() + for i, opt in ipairs(sortedOptions) do + local menuBtn = menuButtons[i] + if menuBtn and not opt.header then + if opt.color then + -- per-option colour (e.g. class-coloured spec list) always wins + menuBtn.Text:SetTextColor(opt.color.r, opt.color.g, opt.color.b) + elseif currentVal == opt.key then + menuBtn.Text:SetTextColor(selColor.r, selColor.g, selColor.b) + else + menuBtn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + end + end + if searchable then + menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 44)) + scrollChild:SetWidth(menuFrame:GetWidth() - 24) + else + menuFrame:SetWidth(math.max(btn:GetWidth() or 0, menuContentW + 24)) + end + menuFrame:Show() + S.currentOpenDropdown = menuFrame + if searchBox then searchBox:SetFocus() end + end + end) + + btn:SetScript("OnShow", UpdateText) + UpdateText() + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so its preview/value (texture swatch, font preview, + -- selected text) greys with the label rather than staying full-bright. + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + UpdateText() -- restore per-option colour on the selected label + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + btn.Text:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The label + -- sits at the container's TOPLEFT, above the opener, so it is well clear of + -- the menu you are about to click. + GUI:AttachTooltip(container, label, lbl) + + -- SEARCH: Register this setting + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterDropdown(label, dbKey, options, nil, callback) + end + + return container +end + +-- ============================================================ +-- OUTLINE + SHADOW CONTROLS +-- A flag dropdown and a shadow checkbox that both bind to a single stored +-- outline value (see DF:OutlineFlag / OutlineHasShadow / ComposeOutline in +-- Config.lua). Shadow is decoupled from the outline flag so any flag can be +-- combined with a drop shadow, mirroring Grid2's font options. +-- ============================================================ + +local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "MONOCHROME, OUTLINE", "MONOCHROME, THICKOUTLINE" } + +function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) + local options = { + NONE = L["None"], + OUTLINE = L["Outline"], + THICKOUTLINE = L["Thick Outline"], + MONOCHROME = L["Monochrome"], + ["MONOCHROME, OUTLINE"] = L["Monochrome Outline"], + ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], + _order = OUTLINE_FLAG_ORDER, + } + local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end + local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end + return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) +end + +function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) + local get = function() return DF:OutlineHasShadow(dbTable[dbKey]) end + local set = function(val) dbTable[dbKey] = DF:ComposeOutline(DF:OutlineFlag(dbTable[dbKey]), val) end + return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) +end + +-- ============================================================ +-- UNIFIED BORDER CONTROL SET +-- Drops the canonical Show / Style / Texture / Size / Colour controls plus +-- whichever optional Phase B controls the consumer opts into (offset, inset, +-- blendMode, gradient, shadow). Saved-variable keys are built from a single +-- camelCase `prefix` (e.g. "defensiveIcon" → "defensiveIconBorderSize"), so +-- consumers add one call instead of hand-rolling ~6-15 widgets each. +-- +-- Each opts.include flag is per-element: "tailor-made to what makes logical +-- sense" — the API exposes everything, but consumers opt in only to what fits +-- their element. Returns a table of widget references so the caller can add +-- per-element extras (dispel-type colour, pulsate, etc.) afterwards. +-- +-- opts = { +-- parent = the panel widget (e.g. self.child) — same first arg the +-- underlying CreateCheckbox/Slider/etc. take +-- include = { offset=, inset=, blendMode=, gradient=, shadow=, +-- classColor=, roleColor=, colorByTime=, colorByType= } +-- fullUpdate = callback for full re-render (drop / value-set) +-- lightUpdate = callback for slider-drag (size, offsets, shadow sliders) +-- lightColors = callback for live colour-picker preview +-- refreshStates = optional hook fired when Show/Gradient/Shadow toggles +-- change visibility of other widgets +-- disableWhen = optional predicate fn(db) → bool. When true, EVERY widget +-- (including the Show toggle itself) GREYS OUT in place. This +-- is what a consumer whose border sits under a feature toggle +-- wants — the addon-wide rule is that a deactivated control +-- greys where it is, so the page doesn't reflow and you can +-- still see what turning the feature on would give you. +-- hideWhen = optional predicate fn(db) → bool. When true, EVERY widget +-- (including the Show toggle itself) HIDES. Reserve this for a +-- gate that changes WHAT the page offers — a variant switch +-- like Pinned Frames' per-set border override, where the +-- controls belong to a mode you are not in. A plain on/off +-- feature toggle is disableWhen, not this. +-- sizeMin / sizeMax / sizeStep = slider range overrides +-- offsetMin / offsetMax / offsetStep +-- } +-- ============================================================ +-- CreateAnimationControls — the Border Animation control set +-- (Type dropdown + every per-effect tunable), extracted so the base +-- Border Animation panel (CreateBorderControls / include.animate) AND +-- Aura Designer's Expiring Animation override render an IDENTICAL set of +-- widgets from ONE source. Add or remove an effect / tunable here and both +-- panels update together — no drift. +-- +-- group = SettingsGroup the widgets are added to +-- dbTable = db / proxy the widgets read & write +-- animPrefix = key namespace; widgets target dbTable[animPrefix .. suffix] +-- (base border: "BorderAnimation"; AD expiring: +-- "ExpiringAnimation") +-- opts: +-- parent = frame parent for the widgets +-- fullUpdate = heavy refresh callback (dropdown / slider-release / colour) +-- lightUpdate = light refresh callback (slider-drag) +-- lightColors = live colour-picker preview callback (needed for AD's +-- proxy, whose sub-table colour writes skip __newindex) +-- typeLabel = label for the Type dropdown +-- hideExtra = optional predicate; when true the WHOLE block hides +-- (the border panel folds the block under Show Border; +-- the always-visible Expiring override omits it) +-- onTypeChange = runs after the Type dropdown changes (re-layout / reflow) +-- perfBanner = show the per-border FPS warning banner (default true) +-- Returns the widget table (animationType, animationColor, … ) so the caller +-- can merge the handles into its own control table. +-- ============================================================ +function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) + opts = opts or {} + local parent = opts.parent + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local typeLabel = opts.typeLabel or L["Border Animation"] + local excludeTypes = opts.excludeTypes -- optional set of animation-type keys to omit from the dropdown + local hideExtra = opts.hideExtra + local onTypeChange = opts.onTypeChange or function() end + local showPerfBanner = opts.perfBanner ~= false + + local function aKey(suffix) return animPrefix .. suffix end + local animTypeKey = aKey("Type") + local function animType() return dbTable[animTypeKey] or "NONE" end + local function extraOff() return (hideExtra and hideExtra()) or false end + local function animOff() return extraOff() or animType() == "NONE" end + + -- Sets of effect types each tunable applies to (truthiness on a + -- string-keyed set). Mirrors the per-effect parameter map — keep in + -- sync with StartAnimation's branches in Frames/Border.lua. + -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash + -- thickness, Inset = dash inset. + local hasFrequency = { DF_PULSATE=1, + DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } + local hasParticles = { DF_ORBIT=1, DF_PIXEL=1 } + -- CORNERS_ONLY is hidden from the type dropdown below, but keep its param + -- entries so an indicator that still carries a saved CORNERS_ONLY value shows + -- the right controls. + local hasThickness = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_PIXEL=1 } + -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which + -- modulates the border's own edges and has no separate animRect). + local hasPositioning = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } + -- Scale slider = sparkle size (DF Chase). + local hasScale = { DF_ORBIT=1 } + -- Length slider = bar length (DF Pixel's chasing bars). + local hasLength = { DF_PIXEL=1 } + local cornersOnly = { CORNERS_ONLY=1 } + local function hideUnless(set) + return function() + if animOff() then return true end + return not set[animType()] + end + end + + local w = {} + + -- All DF-owned border effects (no external glow library). The "DF " labels + -- are kept from when they sat alongside the retired LCG glows. + local animTypeOptions = { + NONE = L["None"], + DF_PULSATE = L["DF Pulsate"], + DF_ORBIT = L["DF Chase"], + DF_DASH = L["DF Dash"], + DF_FLASH = L["DF Flash"], + DF_PIXEL = L["DF Pixel"], + DF_PROC = L["DF Proc"], + BLINK = L["Blink"], + -- None first (the "off" option), then alphabetical by label. CORNERS_ONLY + -- is intentionally absent — it's kept in the engine (an existing saved + -- value still renders) but no longer offered as a pickable animation. + _order = { "NONE", "BLINK", "DF_ORBIT", + "DF_DASH", "DF_FLASH", "DF_PIXEL", "DF_PROC", "DF_PULSATE" }, + } + -- Optional caller filter: drop any excluded type from both the value map and + -- the display order (e.g. the Aura Designer border offers only the taint-safe, + -- overlay-recoverable animations — no LCG glows). + if excludeTypes then + for k in pairs(excludeTypes) do animTypeOptions[k] = nil end + local filteredOrder = {} + for _, k in ipairs(animTypeOptions._order) do + if not excludeTypes[k] then filteredOrder[#filteredOrder + 1] = k end + end + animTypeOptions._order = filteredOrder + end + w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, + animTypeOptions, + dbTable, animTypeKey, onTypeChange), 55) + -- Type dropdown respects only the extra gate (e.g. Show Border). With no + -- extra gate (Expiring override) it's always visible. + w.animationType.hideOn = hideExtra or function() return false end + + -- Perf warning: animations run an OnUpdate (or LCG internal animation) + -- per active border, which adds up in 20-30 player raids. + if showPerfBanner then + -- staticHeight ONLY where the host reflows widget WIDTHS on every layout + -- pass — i.e. the Aura Designer indicator card (its parent carries + -- dfAD_ReflowWidgets). There a self-sizing banner feeds a SetHeight -> + -- OnSizeChanged -> relayout -> SetWidth loop that drops FPS, so we predict + -- a fixed height instead. On normal settings pages the host lays out once, + -- so the banner MUST self-size to its wrapped text: a fixed height + -- overflows (text spills past the box) on narrow windows until a manual + -- drag forces a relayout. + local reflowingHost = parent and parent.dfAD_ReflowWidgets ~= nil + local perfBanner = GUI:CreateInfoBanner(parent, { + tone = "caution", + text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], + staticHeight = reflowingHost or nil, + minHeight = 56, + }) + w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) + w.animationPerfBanner.hideOn = animOff + end + + -- Animation colour applies to every effect except DF_PULSATE (which + -- modulates the border's own edge alpha — no separate colour). lightColors + -- is threaded through so AD's proxy gets live preview while dragging. + w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Color"], + dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.animationColor.hideOn = function() + return animOff() or animType() == "DF_PULSATE" + end + + -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. + -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), + -- and the OnUpdate effects fall back to a sensible default period at 0. + w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], + 0, 4, 0.05, dbTable, aKey("Frequency"), + fullUpdate, lightUpdate, true), 55) + w.animationFrequency.hideOn = hideUnless(hasFrequency) + -- ⚠ This slider genuinely means different things per effect (see the comment + -- above), which is exactly why it needs saying out loud — nobody discovers + -- "0 = hold still" by dragging. + w.animationFrequency.tooltip = L["How fast the effect runs. On DF Dash this is how quickly the dashes march around the edge, and 0 holds them still. On the others it is the pulse rate, where 0 means the effect's own default speed."] + + w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], + 1, 16, 1, dbTable, aKey("Particles"), + fullUpdate, lightUpdate, true), 55) + w.animationParticles.hideOn = hideUnless(hasParticles) + w.animationParticles.tooltip = L["How many separate lights travel around the border. More reads as busier and costs a little more to draw."] + + w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], + 1, 30, 1, dbTable, aKey("Length"), + fullUpdate, lightUpdate, true), 55) + w.animationLength.hideOn = hideUnless(hasLength) + w.animationLength.tooltip = L["How long each moving segment is. Short values read as darting sparks, long ones as a sweeping tail."] + + w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], + 1, 12, 1, dbTable, aKey("Thickness"), + fullUpdate, lightUpdate, true), 55) + w.animationThickness.hideOn = hideUnless(hasThickness) + w.animationThickness.tooltip = L["How heavy the moving effect is. Separate from Border Thickness — the animation draws on its own layer, so it can be thicker or thinner than the border underneath."] + + w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], + 0.5, 3, 0.05, dbTable, aKey("Scale"), + fullUpdate, lightUpdate, true), 55) + w.animationScale.hideOn = hideUnless(hasScale) + + w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], + -50, 50, 1, dbTable, aKey("Inset"), + fullUpdate, lightUpdate, true), 55) + w.animationInset.hideOn = hideUnless(hasPositioning) + w.animationInset.tooltip = L["Moves the effect in or out from the edge, independently of the border. Push it outward to make a glow spill past the frame."] + + w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], + -50, 50, 1, dbTable, aKey("OffsetX"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetX.hideOn = hideUnless(hasPositioning) + + w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], + -50, 50, 1, dbTable, aKey("OffsetY"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetY.hideOn = hideUnless(hasPositioning) + + -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). + w.animationHideIntro = group:AddWidget(GUI:CreateCheckbox(parent, L["Hide Intro Flash"], + dbTable, aKey("ProcStart"), fullUpdate), 30) + w.animationHideIntro.hideOn = hideUnless({ DF_FLASH = 1, DF_PROC = 1 }) + w.animationHideIntro.tooltip = L["These effects open with a one-off burst before settling into their loop. Turn this on to skip the burst and go straight to the loop."] + + w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], + 2, 40, 1, dbTable, aKey("CornerLength"), + fullUpdate, lightUpdate, true), 55) + w.animationCornerLength.hideOn = hideUnless(cornersOnly) + w.animationCornerLength.tooltip = L["How far the effect runs along each edge from the corner before stopping. Small values leave four short brackets instead of a full outline."] + + return w +end + +-- ============================================================ +function GUI:CreateBorderControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local refreshStates = opts.refreshStates + local hideWhen = opts.hideWhen + local disableWhen = opts.disableWhen + + local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 + local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 + + local function key(suffix) return prefix .. suffix end + local showKey = key("ShowBorder") + -- The Show toggle only respects the parent-level hideWhen. Everything + -- else respects hideWhen OR the Show toggle being off. + -- + -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is + -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` + -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, + -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. + -- For consumers with a different dbTable — notably Aura Designer's + -- per-aura proxy — this is the only way the visibility predicates see + -- the actual border state (e.g. proxy.BorderStyle, not the unrelated + -- DF.db.party.BorderStyle which doesn't exist). + local function hideShow() return hideWhen and hideWhen(dbTable) or false end + -- Show Border OFF no longer HIDES the border controls — they stay visible and + -- GREY OUT (disableOn = borderOff, applied by the loop at the end of this + -- function) so the panel previews them. `hideOff` now means "hidden by the + -- parent/variant gate only" (whatever the consumer passes via hideWhen); the + -- name is kept so the existing `.hideOn = hideOff` references read unchanged. + local function hideOff() return hideShow() end + local function borderOff() return dbTable[showKey] == false end + + local w = {} + + -- opts.noShowToggle: suppress the built-in "Show Border" checkbox for + -- consumers that gate the whole border on an external toggle (e.g. the + -- Targeted Spells "Highlight Important Spells" master). With the checkbox + -- gone, showKey stays nil so hideOff() reduces to hideShow() — the toolkit + -- shows/hides purely on the external hideWhen. + if not opts.noShowToggle then + w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.show.hideOn = hideShow + end + + -- Slider label reads "Border Thickness" (more meaningful than "Size") but + -- the underlying db key stays `BorderSize` and spec.size in the + -- backend stays the same — purely a user-facing rename, no migration. + w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, + dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) + w.size.hideOn = hideOff + + -- Gradient is a STYLE, not a separate toggle. When the consumer opts into + -- gradient via include.gradient, we expose GRADIENT as a third dropdown + -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. + local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], + _order = { "SOLID", "TEXTURE" } } + if include.gradient then + styleOptions.GRADIENT = L["Gradient"] + -- Insert GRADIENT between SOLID and TEXTURE so the order reads + -- "simple colour → two colours → custom texture" in the dropdown. + styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } + end + w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], + styleOptions, dbTable, key("BorderStyle"), function() + -- Match the frame border: pick the first LSM border when switching + -- to Texture without one configured. + if dbTable[key("BorderStyle")] == "TEXTURE" then + local list = DF.GetBorderList and DF:GetBorderList() or nil + local t = dbTable[key("BorderTexture")] + if list and (not t or t == "" or t == "SOLID") then + dbTable[key("BorderTexture")] = next(list) + end + end + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.style.hideOn = hideOff + + -- isGradient is declared up here so the Style-dependent widget cluster + -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) + -- can sit immediately below the Style dropdown — the consequence of the + -- user's style choice reads top-to-bottom without scrolling past + -- unrelated inset / offset / blend controls first. + local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end + + w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], + DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) + w.texture.hideOn = function() + return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" + end + + -- Gradient pickers — only visible under Style = GRADIENT. Grouped here + -- (between Texture and the Colour Source dropdown) so all style-dependent + -- widgets sit directly under the Style dropdown that controls them. + -- The standalone "Border Gradient" checkbox was removed when Style + -- absorbed it; Style is now the single source of truth so it's not + -- possible to pick "Solid + Class Color" then have a Gradient checkbox + -- stomp the class colour (the previous UX bug). Legacy + -- `BorderGradientEnabled = true` profiles are migrated to + -- `BorderStyle = "GRADIENT"` on db load. + if include.gradient then + local function gradHide() return hideOff() or not isGradient() end + + w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Color"], + dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) + w.gradientStart.hideOn = gradHide + w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Color"], + dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) + w.gradientEnd.hideOn = gradHide + w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], + { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, + dbTable, key("BorderGradientDirection"), fullUpdate), 55) + w.gradientDirection.hideOn = gradHide + end + + -- Colour Source dropdown sits ABOVE the colour picker so the relationship + -- "source → resulting colour" reads top-to-bottom in the panel. The + -- options table is built dynamically: Static is always present; Class + -- and Role are added if the consumer opted in via the matching include. + -- Hidden in GRADIENT style — gradient owns its own colours, no resolver + -- chain applies (see Border:BuildSpec). + local sourceKey = key("BorderColorSource") + local hasSourceDropdown = include.classColor or include.roleColor + if hasSourceDropdown then + local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } + if include.classColor then + sourceOptions.CLASS = L["Class"] + sourceOptions._order[#sourceOptions._order + 1] = "CLASS" + end + if include.roleColor then + sourceOptions.ROLE = L["Role"] + sourceOptions._order[#sourceOptions._order + 1] = "ROLE" + end + -- Default the source from the legacy boolean keys when first opened. + if dbTable[sourceKey] == nil then + if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" + elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" + else dbTable[sourceKey] = "STATIC" end + end + w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], + sourceOptions, dbTable, sourceKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.colorSource.hideOn = function() return hideOff() or isGradient() end + w.colorSource.tooltip = L["Where the border colour comes from. Static uses the colour below; Class and Role read it from the unit, so the border tells you who you are looking at without reading the name."] + end + + -- Static colour picker — only visible when source is STATIC (or when the + -- consumer didn't enable any resolver at all, so source doesn't exist). + -- Hidden in GRADIENT style (gradient uses its own start/end pickers). + w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), + true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.color.hideOn = function() + if hideOff() or isGradient() then return true end + if hasSourceDropdown then + local src = dbTable[sourceKey] or "STATIC" + return src ~= "STATIC" + end + return false + end + + -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes + -- the SAME alpha component the colour picker exposes + -- (BorderColor.a), so the slider is just a convenient handle for + -- the picker's alpha bar — no separate alpha key to migrate or keep in + -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the + -- two gradient pickers each carry their own alpha, and a single slider + -- has no obvious meaning). + if include.alpha then + -- Ensure the underlying colour table has an alpha component so the + -- slider doesn't read nil on first open. The picker also seeds .a but + -- we don't depend on widget-creation order. + local c = dbTable[key("BorderColor")] + if type(c) ~= "table" then + c = { r = 0, g = 0, b = 0, a = 1 } + dbTable[key("BorderColor")] = c + end + if c.a == nil then c.a = 1 end + + -- Read-time nil-guard: these closures fire on the slider's OnShow at + -- arbitrary later times (tab/page re-show, mode switch), NOT just at + -- creation. The seed above only guarantees the table exists NOW — a proxy + -- dbTable can resolve BorderColor to nil later (e.g. re-showing the AD page + -- for a mode whose config doesn't surface the key), so re-read and guard + -- each call instead of assuming the table is still there. + w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, + nil, nil, fullUpdate, lightColors or lightUpdate, true, + function() + local bc = dbTable[key("BorderColor")] + return (bc and bc.a) or 1 + end, + function(v) + local bc = dbTable[key("BorderColor")] + if bc then bc.a = v end + end), 55) + w.alpha.hideOn = function() return hideOff() or isGradient() end + end + + if include.inset then + w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, + dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) + w.inset.hideOn = hideOff + -- Thickness / Inset / Offset are three similar-sounding sliders that do + -- different things; the tooltip lives here because Inset is the one + -- nobody guesses. + w.inset.tooltip = L["Pulls the border inward (positive) or pushes it outward (negative) from the edge. Thickness is how heavy the line is, Inset is how far in it sits, Offset slides the whole border sideways."] + end + + if include.offset then + w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, + dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) + w.offsetX.hideOn = hideOff + w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, + dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) + w.offsetY.hideOn = hideOff + -- No tooltip on Offset X/Y, deliberately, and the same goes for every + -- other Offset slider in the addon (~60 of them): an offset is a well + -- understood control and a tooltip restating it is noise. Inset is the + -- one that needs explaining, so the Thickness / Inset / Offset + -- distinction is spelled out THERE, once. Krathe's call, 2026-07-27 — + -- these two briefly had tooltips and Border Shadow's offsets did not, + -- which is the inconsistency that prompted it. + end + + if include.blendMode then + w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], + { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"], DISABLE = L["Disable"] }, + dbTable, key("BorderBlendMode"), fullUpdate), 55) + w.blendMode.hideOn = hideOff + w.blendMode.tooltip = L["How the border colour mixes with whatever is behind it. Blend is normal. Add brightens and is what makes a colour glow. Modulate darkens. Disable ignores opacity entirely and draws the colour flat."] + end + + if include.shadow then + local shadowOnKey = key("BorderShadowEnabled") + w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.shadowEnabled.hideOn = hideOff + -- Border Shadow OFF greys (not hides) its sub-controls — a nested boolean + -- toggle, same grey-everything rule. The end-of-function loop OR-composes + -- borderOff, so these also grey when Show Border is off. + local function shadowOff() return dbTable[shadowOnKey] == false end + + w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Color"], + dbTable, key("BorderShadowColor"), true, fullUpdate), 35) + w.shadowColor.hideOn = hideOff + w.shadowColor.disableOn = shadowOff + w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, + dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) + w.shadowSize.hideOn = hideOff + w.shadowSize.disableOn = shadowOff + w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, + dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetX.hideOn = hideOff + w.shadowOffsetX.disableOn = shadowOff + w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, + dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetY.hideOn = hideOff + w.shadowOffsetY.disableOn = shadowOff + end + + -- ===== Animation (Stage 3) ===== + -- include.animate drops the full Border Animation control set (Type + -- dropdown + per-effect tunables, each with a hideOn keyed to the effect + -- it applies to). Built from the shared GUI:CreateAnimationControls so the + -- base panel and AD's Expiring override never drift. The whole block folds + -- under Show Border via hideExtra = hideOff. Widget handles are merged back + -- onto `w` so existing references (w.animationType, …) are preserved. + if include.animate then + local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { + parent = parent, + fullUpdate = fullUpdate, + lightUpdate = lightUpdate, + lightColors = lightColors, + typeLabel = L["Border Animation"], + -- Optional caller filter, forwarded from the CreateBorderControls call + -- site (e.g. the Aura Designer border restricts to overlay-recoverable + -- animation types). nil for every other caller → full type list. + excludeTypes = opts.animExcludeTypes, + hideExtra = hideOff, + onTypeChange = function() + if refreshStates then refreshStates() end + fullUpdate() + end, + }) + for k, v in pairs(aw) do w[k] = v end + end + + -- ===== Colour resolver toggles (Stage 2) ===== + -- These flip BorderColor's source from the static picker to a per-unit / + -- per-aura / per-tick computation. BuildSpec applies them in priority + -- order (type > time > class > role > static) when the consumer passes + -- ctx to BuildSpec. The static colour picker still controls the fallback + -- (when ctx is missing or the resolver yields nil). + + -- (Colour Source dropdown + Static colour picker + Alpha slider are wired + -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the + -- relationship "source → colour" reads top-to-bottom in the panel.) + + if include.colorByTime then + w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) + w.colorByTime.hideOn = hideOff + -- The actual colour curve picker is consumer-specific (e.g. AD's + -- existing expiring colour curve) and is added by the consumer + -- alongside this checkbox. + end + + if include.colorByType then + w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) + w.colorByType.hideOn = hideOff + end + + -- Two independent greys, both composed on top of whatever disableOn a control + -- already carries (e.g. the shadow sub-controls), and both leaving the + -- variant hideOn untouched: + -- disableWhen — the CONSUMER's gate: the feature this border belongs to is + -- switched off. Applies to EVERY widget including the Show Border + -- checkbox, since with the feature off there is nothing for it to show. + -- borderOff — Show Border itself is off. Applies to everything EXCEPT the + -- Show Border checkbox, which has to stay clickable to turn it back on. + -- RefreshChildStates applies disableOn to group children, and CreateCheckbox + -- auto-refreshes on toggle, so both greys update live. + for k, widget in pairs(w) do + if type(widget) == "table" and widget.SetEnabled then + local prev = widget.disableOn + local isShow = (k == "show") + widget.disableOn = function(d) + if disableWhen and disableWhen(dbTable) then return true end + if not isShow and borderOff() then return true end + return (prev and prev(d)) or false + end + end + end + + return w +end + +-- ============================================================ +-- SHARED TEXT-STYLE CONTROLS (pairs with DF.TextStyle — the engine consumers +-- style FontStrings through). Mirrors CreateBorderControls: one builder, every +-- text block in the addon renders the same control flow instead of a hand-rolled +-- copy per page. Emits, in the pages' established order: +-- Font, Scale, Outline, Shadow, [Color], Anchor, Offset X/Y, [Justify H, Justify V] +-- Key convention: Font/Scale/Outline/Anchor/X/Y/JustifyH/JustifyV/Color. +-- +-- opts: +-- parent REQUIRED — the page scroll child (self.child) +-- include = { color = false, justify = true, anchor = true, offsets = true } +-- colorLabel colour picker label (default L["Text Color"]) +-- disableOn predicate applied to EVERY created widget (page-level gate) +-- hideOn predicate applied to EVERY created widget +-- colorDisableOn EXTRA disable gate for the colour picker only (e.g. colour-by-time on) +-- onChange full-update callback (dropdowns / colour commit) +-- onDrag lightweight slider-drag callback (also colour live-preview) +-- scaleMin/Max/Step, offsetMin/Max — slider ranges (defaults 0.5–2.0 ×0.05, ±150) +-- Returns the created widgets keyed { font, scale, outline, shadow, color, anchor, +-- offsetX, offsetY, justifyH, justifyV } so pages can attach extra gates. +-- ============================================================ +function GUI:CreateTextControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local onChange = opts.onChange + local onDrag = opts.onDrag or onChange + local L = DF.L + + local scaleMin, scaleMax, scaleStep = opts.scaleMin or 0.5, opts.scaleMax or 2.0, opts.scaleStep or 0.05 + local offMin, offMax = opts.offsetMin or -150, opts.offsetMax or 150 + + local function key(suffix) return prefix .. suffix end + local widgets = {} + + -- Apply the shared page gates to a widget, composing with any the widget factory set. + local function gate(w) + if opts.disableOn then + local prev = w.disableOn + w.disableOn = function(d) return (opts.disableOn(d) or (prev and prev(d))) and true or false end + end + if opts.hideOn then w.hideOn = opts.hideOn end + return w + end + + widgets.font = gate(group:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], dbTable, key("Font"), onChange), 55)) + widgets.scale = gate(group:AddWidget(GUI:CreateSlider(parent, L["Scale"], scaleMin, scaleMax, scaleStep, dbTable, key("Scale"), nil, onDrag, true), 55)) + widgets.outline = gate(group:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], dbTable, key("Outline"), onChange), 55)) + widgets.shadow = gate(group:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], dbTable, key("Outline"), onChange), 30)) + + if include.color then + widgets.color = gate(group:AddWidget(GUI:CreateColorPicker(parent, opts.colorLabel or L["Text Color"], dbTable, key("Color"), false, onChange, onDrag, true), 35)) + if opts.colorDisableOn then + local prev = widgets.color.disableOn + widgets.color.disableOn = function(d) return (opts.colorDisableOn(d) or (prev and prev(d))) and true or false end + end + end + + if include.anchor ~= false then + local anchorOptions = { + CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], + TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], + } + widgets.anchor = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], anchorOptions, dbTable, key("Anchor"), onChange), 55)) + -- Anchor vs Justify is the pair people get wrong: one places the text, + -- the other arranges it within its own box. Both say so, from their side. + widgets.anchor.tooltip = L["Which part of the element the text is pinned to. Offset X and Y then nudge it from there."] + end + + if include.offsets ~= false then + widgets.offsetX = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], offMin, offMax, 1, dbTable, key("X"), nil, onDrag, true), 55)) + widgets.offsetY = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], offMin, offMax, 1, dbTable, key("Y"), nil, onDrag, true), 55)) + end + + -- Justify is OPT-IN (include.justify = true). It's redundant with Anchor for short + -- single-token text on a small icon (duration/stacks) and boxing to justify TRUNCATES + -- wide text like "59m" — so the aura pages don't expose it. The DF.TextStyle engine + -- still honors JustifyH/JustifyV keys for a future wide/fixed-region consumer. + if include.justify then + local justifyHOptions = { [""] = L["Default"], LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } + local justifyVOptions = { [""] = L["Default"], TOP = L["Top"], MIDDLE = L["Middle"], BOTTOM = L["Bottom"] } + widgets.justifyH = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify H"], justifyHOptions, dbTable, key("JustifyH"), onChange), 55)) + widgets.justifyH.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] + widgets.justifyV = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify V"], justifyVOptions, dbTable, key("JustifyV"), onChange), 55)) + widgets.justifyV.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] + end + + return widgets +end \ No newline at end of file From 55bf9bb9aa0a66635f39878af28c11115024eae3 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:32:31 +0100 Subject: [PATCH 19/34] Fix the companion's addon table and its stranded login-time work Two runtime faults, both invisible to every checker in the harness, both found from one in-game crash report. 1. Every DF.* read in the companion was nil. Each addon's `...` yields its OWN private table. All 30 companion files still had `local addonName, DF = ...` from before the move, so DF was the companion's empty table and the first `DF.GUI.Colors.x` preamble line blew up -- Cards.lua:9 was simply the first one reached. The companion was entirely non-functional. They now take the parent's table from the global that DandersFrames already exposes via ## AllowAddOnTableAccess. DandersFrames.xml has bound it that way for a while; XML script blocks have no `...` either. addonName was only ever read in one place (Debug/MemoryTest), so it is declared there and nowhere else. That reading also now sums both addons: the memory panel lives in the companion, so the companion is loaded by definition whenever a reading is taken, and measuring only the parent would have quietly omitted the larger half. 2. Login-time work does not run in a load-on-demand addon. The companion loads when the player opens the settings panel. Anything in it waiting on a once-per-session event has already missed it and fails silently -- no error, the feature just never happens. - ClickCasting's bootstrap sat in UI/BindingEditor behind a PLAYER_ENTERING_WORLD gated on isInitialLogin/isReloadingUi. Those are only true on the firing already missed, so CC:Initialize() would never have run and click-casting would never have worked at all. Moved to the resident ClickCasting/Events.lua; CC:Initialize itself was already resident, only its trigger was stranded. - ColorPicker waited for ADDON_LOADED("DandersFrames") to read its saved swatches, and for PLAYER_ENTERING_WORLD to install its hook. The first is dead, which meant swatches loading empty and then being overwritten with nothing on the next save. Both now just run at file scope: the parent and its SavedVariables are loaded before the companion can. docs/reorg-tools/lod_login_check.py (local) is the checker for this class. Verified by running it against the pre-fix files, where it reports both DEAD sites independently. It also flags GUI/Pages/AutoProfiles.lua as DELAYED -- auto-profile switching is driven from a payload event frame, so it stays inert until the panel is opened once. That one is NOT fixed here; it needs the engine half split out to the resident side, the same treatment AuraDesigner/Migrations.lua already got. split_verify's last live entry is now marked historical: the pages moved folder and their preamble changed, both deliberately. It has no live splits left and is a record, not an ongoing check. --- DandersFrames/ClickCasting/Events.lua | 20 ++++++++++ .../AuraDesigner/UI/Cards.lua | 5 ++- .../AuraDesigner/UI/Editor.lua | 5 ++- .../AuraDesigner/UI/Groups.lua | 5 ++- .../AuraDesigner/UI/Indicators.lua | 5 ++- .../AuraDesigner/UI/Options.lua | 5 ++- .../ClickCasting/UI/BindingEditor.lua | 19 ++++----- .../ClickCasting/UI/Dialogs.lua | 5 ++- .../ClickCasting/UI/Main.lua | 5 ++- .../ClickCasting/UI/ProfilesPanel.lua | 5 ++- DandersFrames_Options/Debug/AtlasBrowser.lua | 5 ++- .../Debug/AttachmentScan.lua | 5 ++- DandersFrames_Options/Debug/AuraExplorer.lua | 5 ++- DandersFrames_Options/Debug/MemoryTest.lua | 12 +++++- .../FilterRegistry/UI/Options.lua | 5 ++- .../FilterRegistry/UI/SpellPicker.lua | 5 ++- DandersFrames_Options/GUI/ColorPicker.lua | 39 +++++++++---------- DandersFrames_Options/GUI/Controls.lua | 35 +++++++++-------- DandersFrames_Options/GUI/Pages/Auras.lua | 5 ++- .../GUI/Pages/AutoProfiles.lua | 5 ++- DandersFrames_Options/GUI/Pages/Frames.lua | 5 ++- .../GUI/Pages/Indicators.lua | 5 ++- DandersFrames_Options/GUI/Pages/Modules.lua | 5 ++- .../GUI/Pages/NicknamesPage.lua | 5 ++- .../GUI/Pages/NicknamesPicker.lua | 5 ++- DandersFrames_Options/GUI/Pages/Options.lua | 5 ++- DandersFrames_Options/GUI/Panel.lua | 5 ++- DandersFrames_Options/GUI/Sections.lua | 31 ++++++++------- .../TestMode/TestFramePool.lua | 5 ++- DandersFrames_Options/TestMode/TestMode.lua | 5 ++- .../TextDesigner/UI/Options.lua | 5 ++- 31 files changed, 192 insertions(+), 89 deletions(-) diff --git a/DandersFrames/ClickCasting/Events.lua b/DandersFrames/ClickCasting/Events.lua index d6ada738..8342e586 100644 --- a/DandersFrames/ClickCasting/Events.lua +++ b/DandersFrames/ClickCasting/Events.lua @@ -629,6 +629,26 @@ function CC:GetNameplateClickableFrame(nameplate, unitToken) return nil end +-- ============================================================ +-- BOOTSTRAP +-- ============================================================ +-- ☠ This must stay resident. It used to live in ClickCasting/UI/BindingEditor, +-- which is now in the load-on-demand companion -- and the trigger below only +-- fires on the login/reload PLAYER_ENTERING_WORLD. A companion loaded on demand +-- has already missed it, and the zone-change firings are filtered out by the +-- flags, so click-casting would never have initialised at all. +-- +-- CC:Initialize itself is in ClickCasting/Frames.lua and is resident too. +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +initFrame:SetScript("OnEvent", function(self, event, isInitialLogin, isReloadingUi) + -- Only initialize on first load or reload, not zone changes + if isInitialLogin or isReloadingUi then + CC:Initialize() + self:UnregisterEvent("PLAYER_ENTERING_WORLD") + end +end) + -- Register all currently visible nameplates function CC:RegisterAllNameplates() if not self.db or not self.db.enabled then return end diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 0a8a858b..82599190 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1,6 +1,9 @@ -- Part 4 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local L = DF.L local GUI = DF.GUI local Adapter = DF.AuraDesigner.Adapter diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 04753a14..09999d02 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1,6 +1,9 @@ -- Part 5 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local L = DF.L local GUI = DF.GUI local Adapter = DF.AuraDesigner.Adapter diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 50b0ac87..875dc9c1 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1,6 +1,9 @@ -- Part 2 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local L = DF.L local GUI = DF.GUI local Adapter = DF.AuraDesigner.Adapter diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index 68403e57..c9362e0b 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua @@ -1,6 +1,9 @@ -- Part 3 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local L = DF.L local GUI = DF.GUI local Adapter = DF.AuraDesigner.Adapter diff --git a/DandersFrames_Options/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua index 45c3ca5a..4ce59398 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Options.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Options.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- AURA DESIGNER - OPTIONS GUI diff --git a/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua index 48b95a8a..9fc96a4a 100755 --- a/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua +++ b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- Get module namespace local CC = DF.ClickCast @@ -3728,16 +3731,10 @@ function CC:CreateSpecialActionCell(parent, actionType, label, iconPath) return cell end --- Initialize when player enters world (deferred from ADDON_LOADED for faster load times) -local initFrame = CreateFrame("Frame") -initFrame:RegisterEvent("PLAYER_ENTERING_WORLD") -initFrame:SetScript("OnEvent", function(self, event, isInitialLogin, isReloadingUi) - -- Only initialize on first load or reload, not zone changes - if isInitialLogin or isReloadingUi then - CC:Initialize() - self:UnregisterEvent("PLAYER_ENTERING_WORLD") - end -end) +-- The click-casting bootstrap used to live here. It cannot: this file is in the +-- load-on-demand companion, and its trigger only fires on the login/reload +-- PLAYER_ENTERING_WORLD, which is long gone by the time anything loads us. It +-- now lives in the resident ClickCasting/Events.lua. -- Debug slash command to list all detected spells -- Non-dev: DUMP. Spell detection is a common source of "my bind does nothing" diff --git a/DandersFrames_Options/ClickCasting/UI/Dialogs.lua b/DandersFrames_Options/ClickCasting/UI/Dialogs.lua index c295f4e7..525605f0 100644 --- a/DandersFrames_Options/ClickCasting/UI/Dialogs.lua +++ b/DandersFrames_Options/ClickCasting/UI/Dialogs.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- Get module namespace local CC = DF.ClickCast diff --git a/DandersFrames_Options/ClickCasting/UI/Main.lua b/DandersFrames_Options/ClickCasting/UI/Main.lua index 6aa0d250..2a417be3 100755 --- a/DandersFrames_Options/ClickCasting/UI/Main.lua +++ b/DandersFrames_Options/ClickCasting/UI/Main.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- Get module namespace local CC = DF.ClickCast diff --git a/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua b/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua index 0ed8dd10..22f1b45e 100644 --- a/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua +++ b/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- Get module namespace local CC = DF.ClickCast diff --git a/DandersFrames_Options/Debug/AtlasBrowser.lua b/DandersFrames_Options/Debug/AtlasBrowser.lua index 29b36a80..8549ec1b 100644 --- a/DandersFrames_Options/Debug/AtlasBrowser.lua +++ b/DandersFrames_Options/Debug/AtlasBrowser.lua @@ -1,7 +1,10 @@ -- Atlas Icon Browser for DandersFrames -- Opens with /df debug atlas or /dfatlas (dev builds only) -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local atlasFrame = nil local ICONS_PER_ROW = 12 diff --git a/DandersFrames_Options/Debug/AttachmentScan.lua b/DandersFrames_Options/Debug/AttachmentScan.lua index be8a5c6e..604abfdd 100644 --- a/DandersFrames_Options/Debug/AttachmentScan.lua +++ b/DandersFrames_Options/Debug/AttachmentScan.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- ATTACHMENT SCAN (diagnostic) diff --git a/DandersFrames_Options/Debug/AuraExplorer.lua b/DandersFrames_Options/Debug/AuraExplorer.lua index 4863b556..783e97e1 100644 --- a/DandersFrames_Options/Debug/AuraExplorer.lua +++ b/DandersFrames_Options/Debug/AuraExplorer.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- AURA EXPLORER — /df debug auraexp diff --git a/DandersFrames_Options/Debug/MemoryTest.lua b/DandersFrames_Options/Debug/MemoryTest.lua index 7b6c5c02..d82e4688 100644 --- a/DandersFrames_Options/Debug/MemoryTest.lua +++ b/DandersFrames_Options/Debug/MemoryTest.lua @@ -1,4 +1,9 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames +local addonName = "DandersFrames" +local companionName = "DandersFrames_Options" -- ============================================================ -- MEMORY TEST PANEL @@ -137,9 +142,12 @@ local function CreateReadout(parent, tone) return fs end +-- ☠ DandersFrames is two addons now. This panel lives in the companion, so the +-- companion is loaded by definition whenever a reading is taken -- measuring +-- only the parent would omit the larger half and quietly flatter every result. local function CurrentMemory() UpdateAddOnMemoryUsage() - return GetAddOnMemoryUsage(addonName) + return GetAddOnMemoryUsage(addonName) + GetAddOnMemoryUsage(companionName) end -- Shared body of the timed tests. `seconds` is the window; the panel's own diff --git a/DandersFrames_Options/FilterRegistry/UI/Options.lua b/DandersFrames_Options/FilterRegistry/UI/Options.lua index 80501447..fd35a725 100644 --- a/DandersFrames_Options/FilterRegistry/UI/Options.lua +++ b/DandersFrames_Options/FilterRegistry/UI/Options.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- FILTER REGISTRY - FILTER DESIGNER GUI diff --git a/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua b/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua index d726018d..2f3566b5 100644 --- a/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua +++ b/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- FILTER REGISTRY - SHARED SPELL DATABASE PICKER diff --git a/DandersFrames_Options/GUI/ColorPicker.lua b/DandersFrames_Options/GUI/ColorPicker.lua index aaeb0fa9..da5f54ac 100644 --- a/DandersFrames_Options/GUI/ColorPicker.lua +++ b/DandersFrames_Options/GUI/ColorPicker.lua @@ -4,7 +4,10 @@ -- PLAYER_ENTERING_WORLD and exposes GUI:OpenColorPicker as the public API. -- Dev harness: /dfcolorhook (hook status/install/uninstall; dev builds only). -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local GUI = DF.GUI local L = DF.L local testFrame = nil @@ -95,15 +98,13 @@ local function SaveColorsToDb() DandersFramesDB_v2.colorPickerSquare = preferSquarePicker end --- Load on addon load -local loader = CreateFrame("Frame") -loader:RegisterEvent("ADDON_LOADED") -loader:SetScript("OnEvent", function(self, event, addonName) - if addonName == "DandersFrames" then - LoadSavedColors() - self:UnregisterEvent("ADDON_LOADED") - end -end) +-- ☠ This used to wait for ADDON_LOADED("DandersFrames"). It cannot: this file is +-- in the load-on-demand companion, so by the time it runs the parent has long +-- since loaded and that event will never fire again -- the saved swatches would +-- stay empty and the next SaveColorsToDb would overwrite them with nothing. +-- The parent is loaded by definition (RequiredDeps + LoadAddOn), which also +-- means its SavedVariables are already populated, so just read them now. +LoadSavedColors() -- Helper to create a unique color key local function ColorKey(r, g, b, a) @@ -1993,17 +1994,13 @@ function GUI:IsColorPickerHookInstalled() return midnightHooked or legacyHooked end --- Auto-install hook after addon is fully loaded -local hookInitFrame = CreateFrame("Frame") -hookInitFrame:RegisterEvent("PLAYER_ENTERING_WORLD") -hookInitFrame:SetScript("OnEvent", function(self, event) - -- Always install the hook - the hook itself checks settings when invoked - -- Use a timer to ensure ColorPickerFrame is fully initialized - C_Timer.After(1, function() - GUI:InstallColorPickerHook() - end) - self:UnregisterEvent("PLAYER_ENTERING_WORLD") -end) +-- Always install the hook - the hook itself checks settings when invoked. +-- This used to wait a second past the login PLAYER_ENTERING_WORLD so that +-- ColorPickerFrame was ready. In the companion neither is needed: nothing loads +-- this file until the player opens the settings panel, by which point the login +-- events are long past and ColorPickerFrame exists. Waiting for the next zoning +-- would leave the hook uninstalled for the first swatch clicked. +GUI:InstallColorPickerHook() -- Debug command to check hook status DF:RegisterDebugSlash("DFCOLORHOOK", "Color picker hook status / toggle", true, "/dfcolorhook") diff --git a/DandersFrames_Options/GUI/Controls.lua b/DandersFrames_Options/GUI/Controls.lua index 2906631d..dbc1b3e9 100644 --- a/DandersFrames_Options/GUI/Controls.lua +++ b/DandersFrames_Options/GUI/Controls.lua @@ -1,19 +1,22 @@ --- Part 4 of the GUI toolkit, split from the original GUI.lua. --- These re-declarations are aliases of the SAME objects the first part --- created; they add no state. See docs/reorg-tools/splits.manifest. -local addonName, DF = ... -local GUI = DF.GUI -local L = DF.L -local S = GUI._state -local C_PANEL, C_ELEMENT, C_HOVER, C_TEXT, C_TEXT_DIM = - GUI.Colors.panel, GUI.Colors.element, GUI.Colors.hover, GUI.Colors.text, GUI.Colors.textDim -local CloseOpenDropdown = GUI._priv.CloseOpenDropdown -local GetThemeColor = GUI.GetThemeColor -local SnapLen = GUI.SnapLen -local CreateElementBackdrop = GUI._priv.CreateElementBackdrop -local StyleScrollBar = GUI.StyleScrollBar -local AddOverrideIndicators = GUI._priv.AddOverrideIndicators -local AddOrderListOverrideIndicators = GUI._priv.AddOrderListOverrideIndicators +-- Part 4 of the GUI toolkit, split from the original GUI.lua. +-- These re-declarations are aliases of the SAME objects the first part +-- created; they add no state. See docs/reorg-tools/splits.manifest. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames +local GUI = DF.GUI +local L = DF.L +local S = GUI._state +local C_PANEL, C_ELEMENT, C_HOVER, C_TEXT, C_TEXT_DIM = + GUI.Colors.panel, GUI.Colors.element, GUI.Colors.hover, GUI.Colors.text, GUI.Colors.textDim +local CloseOpenDropdown = GUI._priv.CloseOpenDropdown +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local CreateElementBackdrop = GUI._priv.CreateElementBackdrop +local StyleScrollBar = GUI.StyleScrollBar +local AddOverrideIndicators = GUI._priv.AddOverrideIndicators +local AddOrderListOverrideIndicators = GUI._priv.AddOrderListOverrideIndicators -- ============================================================ -- EXPIRATION CONTROLS (shared) — the 12.1-safe Expiration panel. Pairs with the -- DF.Expiration engine (Features/Expiration.lua): the engine turns the expiryAlert* keys diff --git a/DandersFrames_Options/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua index 9973f7ed..1ee9db15 100644 --- a/DandersFrames_Options/GUI/Pages/Auras.lua +++ b/DandersFrames_Options/GUI/Pages/Auras.lua @@ -1,6 +1,9 @@ -- Part 3 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) pagePinnedFrames:SetScript("OnHide", function() diff --git a/DandersFrames_Options/GUI/Pages/AutoProfiles.lua b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua index 9f6956d4..9442cace 100644 --- a/DandersFrames_Options/GUI/Pages/AutoProfiles.lua +++ b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- AUTO PROFILES UI - Raid-only automatic profile switching diff --git a/DandersFrames_Options/GUI/Pages/Frames.lua b/DandersFrames_Options/GUI/Pages/Frames.lua index 0757dd77..3dda6248 100644 --- a/DandersFrames_Options/GUI/Pages/Frames.lua +++ b/DandersFrames_Options/GUI/Pages/Frames.lua @@ -1,6 +1,9 @@ -- Part 2 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart2(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) local pageGlobalFonts = CreateSubTab("general", "general_fonts", L["Global Fonts"]) diff --git a/DandersFrames_Options/GUI/Pages/Indicators.lua b/DandersFrames_Options/GUI/Pages/Indicators.lua index a09ce116..8a5eb38e 100644 --- a/DandersFrames_Options/GUI/Pages/Indicators.lua +++ b/DandersFrames_Options/GUI/Pages/Indicators.lua @@ -1,6 +1,9 @@ -- Part 4 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart4(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) BuildPage(pageBuffs, function(self, db, Add, AddSpace, AddSyncPoint) diff --git a/DandersFrames_Options/GUI/Pages/Modules.lua b/DandersFrames_Options/GUI/Pages/Modules.lua index f21005c7..c7cf3d74 100644 --- a/DandersFrames_Options/GUI/Pages/Modules.lua +++ b/DandersFrames_Options/GUI/Pages/Modules.lua @@ -1,6 +1,9 @@ -- Part 5 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart5(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) BuildPage(pageIcons, function(self, db, Add, AddSpace, AddSyncPoint) diff --git a/DandersFrames_Options/GUI/Pages/NicknamesPage.lua b/DandersFrames_Options/GUI/Pages/NicknamesPage.lua index 56681a9a..7670bfa0 100644 --- a/DandersFrames_Options/GUI/Pages/NicknamesPage.lua +++ b/DandersFrames_Options/GUI/Pages/NicknamesPage.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- NICKNAMES - OPTIONS GUI diff --git a/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua b/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua index 96cc1d38..52b30f48 100644 --- a/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua +++ b/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- NICKNAMES - SOURCE PICKER (Phase 2) diff --git a/DandersFrames_Options/GUI/Pages/Options.lua b/DandersFrames_Options/GUI/Pages/Options.lua index 74434cd8..d9e2fa51 100644 --- a/DandersFrames_Options/GUI/Pages/Options.lua +++ b/DandersFrames_Options/GUI/Pages/Options.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local format = string.format -- ============================================================ diff --git a/DandersFrames_Options/GUI/Panel.lua b/DandersFrames_Options/GUI/Panel.lua index efa70728..ea092451 100644 --- a/DandersFrames_Options/GUI/Panel.lua +++ b/DandersFrames_Options/GUI/Panel.lua @@ -1,7 +1,10 @@ -- Part 5 of the GUI toolkit, split from the original GUI.lua. -- These re-declarations are aliases of the SAME objects the first part -- created; they add no state. See docs/reorg-tools/splits.manifest. -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local GUI = DF.GUI local L = DF.L local S = GUI._state diff --git a/DandersFrames_Options/GUI/Sections.lua b/DandersFrames_Options/GUI/Sections.lua index 2339fd6d..8dc76cf0 100644 --- a/DandersFrames_Options/GUI/Sections.lua +++ b/DandersFrames_Options/GUI/Sections.lua @@ -1,17 +1,20 @@ --- Part 2 of the GUI toolkit, split from the original GUI.lua. --- These re-declarations are aliases of the SAME objects the first part --- created; they add no state. See docs/reorg-tools/splits.manifest. -local addonName, DF = ... -local GUI = DF.GUI -local L = DF.L -local S = GUI._state -local P = GUI._priv -local C_BACKGROUND, C_TEXT, C_TEXT_DIM = - GUI.Colors.background, GUI.Colors.text, GUI.Colors.textDim -local ResolveRowHeight = GUI.ResolveRowHeight -local GetThemeColor = GUI.GetThemeColor -local SnapLen = GUI.SnapLen -local CreateElementBackdrop = GUI._priv.CreateElementBackdrop +-- Part 2 of the GUI toolkit, split from the original GUI.lua. +-- These re-declarations are aliases of the SAME objects the first part +-- created; they add no state. See docs/reorg-tools/splits.manifest. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames +local GUI = DF.GUI +local L = DF.L +local S = GUI._state +local P = GUI._priv +local C_BACKGROUND, C_TEXT, C_TEXT_DIM = + GUI.Colors.background, GUI.Colors.text, GUI.Colors.textDim +local ResolveRowHeight = GUI.ResolveRowHeight +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local CreateElementBackdrop = GUI._priv.CreateElementBackdrop function GUI:CreateSettingsGroup(parent, width, opts) -- opts can be a boolean (legacy: collapsible) or a table { collapsible, showSummary, onCollapseChanged } diff --git a/DandersFrames_Options/TestMode/TestFramePool.lua b/DandersFrames_Options/TestMode/TestFramePool.lua index 7cf501dc..39ae9a1b 100755 --- a/DandersFrames_Options/TestMode/TestFramePool.lua +++ b/DandersFrames_Options/TestMode/TestFramePool.lua @@ -4,7 +4,10 @@ -- These frames are completely independent of live header children -- ============================================================ -local _, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- Test frame storage DF.testPartyFrames = {} -- [0]=player, [1-4]=party diff --git a/DandersFrames_Options/TestMode/TestMode.lua b/DandersFrames_Options/TestMode/TestMode.lua index 9b4481ff..0b0a75c3 100644 --- a/DandersFrames_Options/TestMode/TestMode.lua +++ b/DandersFrames_Options/TestMode/TestMode.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local L = DF.L -- ============================================================ diff --git a/DandersFrames_Options/TextDesigner/UI/Options.lua b/DandersFrames_Options/TextDesigner/UI/Options.lua index 867cb1b9..2ca2362e 100644 --- a/DandersFrames_Options/TextDesigner/UI/Options.lua +++ b/DandersFrames_Options/TextDesigner/UI/Options.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames local L = DF.L From cff68e4d947f1e4e67f2cf6000e14013f835327d Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:39:04 +0100 Subject: [PATCH 20/34] AutoProfiles: rebuild CONTENT_TYPES in place RefreshContentTypes reassigned the local on every locale refresh. Harmless while the whole feature was one file; not harmless once the settings page is split out into the load-on-demand companion, because the page can only reach this table by reference. A reassignment would leave the page holding the previous table forever -- stale labels, no error. Rebuilding in place keeps the table's identity stable, so any reference taken anywhere stays valid. Preparation for the engine/page split; no behaviour change on its own. --- DandersFrames_Options/GUI/Pages/AutoProfiles.lua | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/DandersFrames_Options/GUI/Pages/AutoProfiles.lua b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua index 9442cace..de4801aa 100644 --- a/DandersFrames_Options/GUI/Pages/AutoProfiles.lua +++ b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua @@ -83,8 +83,13 @@ end -- registered refresh fn so Core rebuilds them after the overlay. local CONTENT_TYPES = {} +-- ☠ Rebuilds IN PLACE. It used to reassign CONTENT_TYPES, which was harmless +-- while everything lived in one file. It is not now: the settings page reads +-- this table from _priv, and a locale refresh reassigning the engine's local +-- would leave the page holding the previous table forever -- stale labels, no +-- error. Keeping the table's identity stable makes any reference safe. local function RefreshContentTypes() - CONTENT_TYPES = { + local rebuilt = { { key = "instanced", title = L["Instanced / PvP"], @@ -110,6 +115,10 @@ local function RefreshContentTypes() isFixed = false, }, } + wipe(CONTENT_TYPES) + for i = 1, #rebuilt do + CONTENT_TYPES[i] = rebuilt[i] + end end RefreshContentTypes() From 17128b6a68a12c423f976e4f9d9a2a91b9426998 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:43:40 +0100 Subject: [PATCH 21/34] Split the Auto Profiles engine out to the resident addon Auto-profile switching is live behaviour: entering a raid should change profile whether or not the player has ever opened the settings panel. It was driven entirely from GUI/Pages/AutoProfiles.lua, which is now load-on-demand, so it stayed inert until the panel was opened once -- silently, with no error. The 4,160-line file is now two: DandersFrames/Core/AutoProfiles.lua 2,239 engine, resident DandersFrames_Options/GUI/Pages/AutoProfiles 1,980 page, load-on-demand The cut is engine vs UI, not sequential parts, so the two halves interleave in the original rather than concatenating. That puts it outside split_verify's model; it was proven byte-identical against d7189563 by re-interleaving the halves at the four recorded cut points and comparing to the original -- all 4,160 moved lines unchanged. alias_check.py covers the pair from here on, and was negative-tested by deleting a publish line to confirm it reports. Resident half: all the shared helpers, edit-mode state, the override system, runtime profile application, runtime write interception, the event frame and the slash commands. Companion half: the page, both dialogs, the editing banner. Two things had to change rather than just move: - CONTENT_TYPES now rebuilds in place (previous commit). It was reassigned on every locale refresh, which would have left the page holding a stale table. - The engine calls four UI methods -- RefreshEditingUI, RefreshTabOverrideStars, ShowSidebarHint, HideSidebarHint -- across 11 sites, none of them guarded. Rather than touch 11 call sites, the engine defines all four as no-ops and the companion overwrites them when it loads. Same pattern as TestMode/Shim, and written out individually rather than looped so grep and lod_gate_check can see them. Without this, auto-profile switching would have errored with the panel unloaded rather than merely doing nothing. TOC: Core\AutoProfiles.lua loads after Core.lua, which it needs for DF:RegisterLocaleRefresh at file scope. Verified: both halves parse, 93 TOC entries all present, load order has 0 violations and 0 unresolved reads, alias_check clean on all four split sets, lod_gate_check 27 guarded / 0 unguarded, lod_login_check 0 dead. --- DandersFrames/Core/AutoProfiles.lua | 2239 ++++++++++++++++ DandersFrames/DandersFrames.toc | 5 + .../GUI/Pages/AutoProfiles.lua | 2326 +---------------- 3 files changed, 2318 insertions(+), 2252 deletions(-) create mode 100644 DandersFrames/Core/AutoProfiles.lua diff --git a/DandersFrames/Core/AutoProfiles.lua b/DandersFrames/Core/AutoProfiles.lua new file mode 100644 index 00000000..6022c701 --- /dev/null +++ b/DandersFrames/Core/AutoProfiles.lua @@ -0,0 +1,2239 @@ +local _, DF = ... + +-- ============================================================ +-- AUTO PROFILES - Raid-only automatic profile switching (ENGINE) +-- +-- ☠ RESIDENT ON PURPOSE. Profile switching is live behaviour: it must work +-- whether or not the player has ever opened the settings panel. The panel +-- itself is DandersFrames_Options/GUI/Pages/AutoProfiles.lua, which is +-- load-on-demand and holds only the page, the dialogs and the editing banner. +-- The table below is shared: this file owns the state and the runtime, the +-- companion adds the UI methods on top when it loads. +-- ============================================================ + +local AutoProfilesUI = {} +DF.AutoProfilesUI = AutoProfilesUI + +local L = DF.L +local format = string.format + +-- Local references + +-- ============================================================ +-- OVERRIDE KEY LABELS +-- The override tooltip and /df debug overrides both list which settings a layout has +-- overridden. They used to print the RAW saved key -- "auraDesignerPreset", +-- "buffBorderSize" -- so users were shown internal camelCase identifiers. Noise at +-- best, and actively misleading where a key's wording no longer matches the UI: +-- the designer keys still say "preset" ON PURPOSE (they are exported, so renaming +-- them would cost a saved-profile migration to change a name nobody should have +-- been seeing) while the UI now calls those Templates. +-- +-- Derived labels can't be translated -- they come from an identifier, not a string +-- table -- but that is no worse than the raw key they replace, and the cases where +-- the wording actually matters are pinned to real locale keys below. +-- ============================================================ + +-- Prettifying these would be WRONG, not merely ugly. +local KEY_LABEL_OVERRIDES = { + auraDesignerPreset = "Aura Designer Template", + textDesignerPreset = "Text Designer Template", +} + +-- Initialisms that must not be title-cased into nonsense ("Oor", "Bg", "Dps"). +local KEY_ACRONYMS = { + oor = "OOR", bg = "BG", pvp = "PvP", ui = "UI", hp = "HP", + dps = "DPS", afk = "AFK", cc = "CC", ad = "AD", td = "TD", +} + +local keyLabelCache = {} + +local function KeyLabel(key) + if type(key) ~= "string" then return tostring(key) end + local cached = keyLabelCache[key] + if cached then return cached end + + local out + local override = KEY_LABEL_OVERRIDES[key] + if override then + out = L[override] or override + else + -- camelCase -> spaced, digits split off, then title-case each word. + local spaced = key:gsub("(%l)(%u)", "%1 %2"):gsub("(%a)(%d)", "%1 %2") + local words = {} + for w in spaced:gmatch("%S+") do + words[#words + 1] = KEY_ACRONYMS[w:lower()] or (w:sub(1, 1):upper() .. w:sub(2)) + end + out = table.concat(words, " ") + end + + keyLabelCache[key] = out + return out +end + +-- Deep-copy a value (recursive for nested tables) +local function DeepCopyValue(value) + if type(value) ~= "table" then return value end + local copy = {} + for k, v in pairs(value) do + copy[k] = DeepCopyValue(v) + end + return copy +end + +-- Content type definitions +-- title/description read L["..."]; at file scope that returns the enUS baseline +-- (the languageOverride overlay runs later, at ADDON_LOADED). Build them in a +-- registered refresh fn so Core rebuilds them after the overlay. +local CONTENT_TYPES = {} + +-- ☠ Rebuilds IN PLACE. It used to reassign CONTENT_TYPES, which was harmless +-- while everything lived in one file. It is not now: the settings page reads +-- this table from _priv, and a locale refresh reassigning the engine's local +-- would leave the page holding the previous table forever -- stale labels, no +-- error. Keeping the table's identity stable makes any reference safe. +local function RefreshContentTypes() + local rebuilt = { + { + key = "instanced", + title = L["Instanced / PvP"], + description = L["Raids, battlegrounds (1-40)"], + minRange = 1, + maxRange = 40, + isFixed = false, + }, + { + key = "mythic", + title = L["Mythic"], + description = L["20 players (fixed)"], + minRange = 20, + maxRange = 20, + isFixed = true, + }, + { + key = "openWorld", + title = L["Open World"], + description = L["World bosses, outdoor raids (1-40)"], + minRange = 1, + maxRange = 40, + isFixed = false, + }, + } + wipe(CONTENT_TYPES) + for i = 1, #rebuilt do + CONTENT_TYPES[i] = rebuilt[i] + end +end + +RefreshContentTypes() +DF:RegisterLocaleRefresh(RefreshContentTypes) + +-- ============================================================ +-- LOCAL HELPERS for db.raid access and snapshot lookups +-- Defined here so all functions in this file can reference them +-- ============================================================ + +-- Parse table-based keys (e.g., "raidGroupVisible_1" -> "raidGroupVisible", 1) +local function ParseTableKey(key) + local tableName, index = key:match("^(.+)_(%d+)$") + if tableName and index then + return tableName, tonumber(index) + end + return nil, nil +end + +-- Parse pinned frame keys (e.g., "pinned.1.scale" -> 1, "scale") +local function ParsePinnedKey(key) + local setIndex, setting = key:match("^pinned%.(%d+)%.(.+)$") + if setIndex and setting then + return tonumber(setIndex), setting + end + return nil, nil +end + +-- Settings that can be overridden per pinned frame set. +-- Decoupled from auto layouts (2026-06-07): pinned frames are a global, +-- per-mode modular highlight. The ONLY thing a raid auto layout overrides is +-- whether each set is shown (enabled) for that layout — everything else is +-- global and edited on the Pinned Frames page. This keeps pinned settings out +-- of the layout-override store entirely (no more stale pinned.N.* data). +local PINNED_OVERRIDABLE = { + enabled = true, +} + +-- Transient / UI-state keys that must NEVER become a layout override — they're +-- not per-layout settings, so capturing them poisons the layout. raidLocked is the +-- culprit: the per-layout Unlock button unlocks frames DURING an edit session, so +-- raidLocked goes false; if editing exits while still unlocked, the diff-scan +-- stored raidLocked=false and the layout then forced the frames unlocked whenever +-- it activated (the Lock/Unlock button read backwards). `locked` is the party +-- equivalent, listed for symmetry. +local NON_OVERRIDABLE_KEYS = { + raidLocked = true, + locked = true, +} + +-- True unless `key` is non-overridable: a pinned key (pinned.N.setting) whose +-- setting isn't overridable, or a transient UI-state key. The single gate that +-- keeps such keys out of the layout override store — used by SetProfileSetting / +-- IsSettingOverridden / the ExitEditing diff-scan / ApplyRuntimeProfile. +local function IsKeyOverridable(key) + local _, setting = ParsePinnedKey(key) + if setting then + return PINNED_OVERRIDABLE[setting] == true + end + if NON_OVERRIDABLE_KEYS[key] then return false end + return true +end + +-- ============================================================ +-- OVERRIDE KEY → TAB MAPPING +-- Maps override key prefixes to sidebar tab IDs for display. +-- Ordered most-specific first to avoid false matches +-- (e.g. "healthText" before "health", "defensiveIcon" before "debuff"). +-- ============================================================ +-- The 3rd element of each row reads L["..."]; at file scope that returns the +-- enUS baseline (the languageOverride overlay runs later, at ADDON_LOADED). +-- Build the map in a registered refresh fn so the tab labels follow the locale. +local OVERRIDE_TAB_MAP = {} + +local function RefreshOverrideTabMap() + OVERRIDE_TAB_MAP = { + -- Display + {"soloMode", "display_visibility", L["Visibility"]}, + {"showMinimapButton", "display_visibility", L["Visibility"]}, + {"restedIndicator", "display_visibility", L["Visibility"]}, + {"hidePlayerFrame", "display_visibility", L["Visibility"]}, + {"hideDefaultPlayerFrame", "display_visibility", L["Visibility"]}, + {"hideBlizzardPartyFrames", "display_visibility", L["Visibility"]}, + {"hideBlizzardRaidFrames", "display_visibility", L["Visibility"]}, + {"showBlizzardSideMenu", "display_visibility", L["Visibility"]}, + {"tooltip", "display_tooltips", L["Tooltips"]}, + {"rangeFade", "display_fading", L["Fading"]}, + {"oor", "display_fading", L["Fading"]}, + {"dead", "display_fading", L["Fading"]}, + {"offline", "display_fading", L["Fading"]}, + {"pet", "display_pets", L["Pet Frames"]}, + -- General (specific before generic) + {"fontShadow", "general_fonts", L["Global Fonts"]}, + {"groupLabel", "general_labels", L["Group Labels"]}, + {"raidTestFrameCount", "general_frame", L["Frame"]}, + {"raidUseGroups", "general_frame", L["Frame"]}, + {"raidGroupVisible", "general_frame", L["Frame"]}, + {"raidPlayerPerRow", "general_frame", L["Frame"]}, + {"raidFlat", "general_frame", L["Frame"]}, + {"frame", "general_frame", L["Frame"]}, + {"background", "general_frame", L["Frame"]}, + {"missingHealth", "general_frame", L["Frame"]}, + {"border", "general_frame", L["Frame"]}, + {"anchor", "general_frame", L["Frame"]}, + {"sort", "general_sorting", L["Sorting"]}, + {"selfPosition", "general_sorting", L["Sorting"]}, + {"rolePriority", "general_sorting", L["Sorting"]}, + {"classPriority", "general_sorting", L["Sorting"]}, + {"colorPickerOverride", "general_integrations", L["Integrations"]}, + {"colorPickerGlobalOverride", "general_integrations", L["Integrations"]}, + -- Bars (specific text keys before generic "health" prefix). + -- healthTexture MUST precede healthText: it's the health BAR texture, but + -- a bare prefix match classifies it as health TEXT (and the legacy-text + -- cleanup would then delete a live override). + {"healthTexture", "bars_health", L["Health Bar"]}, + {"healthText", "text_health", L["Health Text"]}, + {"healthFont", "text_health", L["Health Text"]}, + {"health", "bars_health", L["Health Bar"]}, + {"classColor", "bars_health", L["Health Bar"]}, + {"smoothBars", "bars_health", L["Health Bar"]}, + {"resourceBar", "bars_resource", L["Resource Bar"]}, + {"absorbBar", "bars_absorb", L["Absorbs"]}, + {"healAbsorb", "bars_absorb", L["Absorbs"]}, + {"healPrediction", "bars_healpred", L["Heal Prediction"]}, + -- Text + {"statusText", "text_status", L["Status Text"]}, + {"textDesigner", "text_designer", L["Text Designer"]}, + {"name", "text_name", L["Name Text"]}, + -- Auras (specific before generic) + -- myBuffIndicator removed — feature deprecated and hidden from UI + {"missingBuff", "auras_missingbuffs", L["Missing Buffs"]}, + {"defensiveIcon", "auras_defensiveicon", L["Defensive Icon"]}, + {"debuff", "auras_debuffs", L["Debuffs"]}, + {"buff", "auras_buffs", L["Buffs"]}, + {"dispel", "auras_dispel", L["Dispel Overlay"]}, + {"auraDesigner", "auras_auradesigner", L["Aura Designer"]}, + -- Indicators (specific before generic) + {"personalTargeted", "indicators_personal_targeted", L["Personal Targeted"]}, + {"targetedList", "indicators_targetedlist", L["Targeted List"]}, + -- ⚰ DEPRECATED-TARGETED-SPELLS — kept on purpose for now: this row is what + -- labels an existing profile's stale targetedSpell* overrides. Dropping it + -- would leave those overrides unattributed rather than making them go away. + -- It goes when the keys do. See Features\TargetedSpells.lua. + {"targetedSpell", "indicators_targetedspells", L["Targeted Spells"]}, + {"roleIcon", "indicators_icons", L["Icons"]}, + {"leaderIcon", "indicators_icons", L["Icons"]}, + {"raidTargetIcon", "indicators_icons", L["Icons"]}, + {"readyCheckIcon", "indicators_icons", L["Icons"]}, + {"summonIcon", "indicators_icons", L["Icons"]}, + {"resurrectionIcon", "indicators_icons", L["Icons"]}, + {"phasedIcon", "indicators_icons", L["Icons"]}, + {"afkIcon", "indicators_icons", L["Icons"]}, + {"vehicleIcon", "indicators_icons", L["Icons"]}, + {"raidRoleIcon", "indicators_icons", L["Icons"]}, + {"statusIconFont", "indicators_icons", L["Icons"]}, + {"selectionHighlight", "indicators_highlights", L["Highlights"]}, + {"hoverHighlight", "indicators_highlights", L["Highlights"]}, + {"aggroHighlight", "indicators_highlights", L["Highlights"]}, + {"aggro", "indicators_highlights", L["Highlights"]}, + -- Pinned frames (prefix match for "pinned.N.setting" format) + {"pinned.", "general_pinnedframes", L["Pinned Frames"]}, + } +end + +RefreshOverrideTabMap() +DF:RegisterLocaleRefresh(RefreshOverrideTabMap) + +-- Returns tabId, tabLabel for a given override key +local function GetOverrideTabId(key) + -- Strip table-based suffix (e.g. "raidGroupVisible_1" → "raidGroupVisible") + local baseKey = key:match("^(.+)_%d+$") or key + for _, entry in ipairs(OVERRIDE_TAB_MAP) do + local prefix = entry[1] + if baseKey:sub(1, #prefix) == prefix then + return entry[2], entry[3] + end + end + return nil, "Unknown" +end + +-- ============================================================ +-- Cleanup: orphaned legacy built-in text overrides in auto-layouts +-- The Text Designer migration took over the built-in Name / Health / Status +-- text. A raid auto-layout edited BEFORE that migration can still carry those +-- legacy text keys (nameFontSize, healthFontSize, statusText*, …) as overrides — +-- now inert (IsLegacyTextHidden hides the legacy FontStrings), so they only +-- clutter the override list beside the layout's textDesignerPreset. Strip them. +-- +-- SAFETY: only runs once the raid TD has ACTUALLY migrated the legacy settings +-- (migratedFromLegacy) — the values are preserved in Text Designer first, and we +-- never strip while the legacy text is still live. Stripping is visually inert +-- (TD ignores these keys); it only declutters. Auto-layouts are raid-only, so we +-- gate on the raid text config. Per-profile guard, idempotent. +-- ============================================================ +-- The DESTRUCTIVE strip decision uses its own boundary-checked matcher, NOT +-- the display-oriented GetOverrideTabId prefix map: a bare prefix grab +-- classifies healthTexture (the health BAR texture) as healthText — deleting a +-- real, live override. A prefix only counts as a legacy text key when the key +-- continues with an uppercase letter or digit (camelCase boundary) or ends +-- exactly there; two legacy oddballs that don't share the prefixes are listed +-- explicitly. +local LEGACY_TEXT_KEY_PREFIXES = { + "nameText", "nameFont", + "healthText", "healthFont", + "statusText", +} +local LEGACY_TEXT_EXACT_KEYS = { + showHealthText = true, + nameColorClass = true, +} +local function IsLegacyTextOverrideKey(key) + if type(key) ~= "string" then return false end + -- Strip table-based suffix ("foo_3" → "foo"), mirroring GetOverrideTabId. + local baseKey = key:match("^(.+)_%d+$") or key + if LEGACY_TEXT_EXACT_KEYS[baseKey] then return true end + for _, prefix in ipairs(LEGACY_TEXT_KEY_PREFIXES) do + local n = #prefix + if baseKey:sub(1, n) == prefix then + local nextChar = baseKey:sub(n + 1, n + 1) + if nextChar == "" or nextChar:match("^[%u%d]") then + return true + end + end + end + return false +end + +local function RaidTextMigrated() + -- The TD legacy migration flags migratedFromLegacy on whatever owns raid text. + -- Preset build: the current raid preset, OR the canonical "Raid" preset the + -- migration always materialises (covers a raid mode repointed elsewhere). + -- Pre-preset build (alpha.6): inline db.raid.textDesigner. + if DF.GetTextDesignerPresets then + if DF.GetModeTextDesigner then + local td = DF:GetModeTextDesigner("raid") + if type(td) == "table" and td.migratedFromLegacy == true then return true end + end + local lib = DF:GetTextDesignerPresets() + local raidPreset = lib and lib["Raid"] + return type(raidPreset) == "table" and raidPreset.migratedFromLegacy == true + end + local rdb = DF.GetRaidDB and DF:GetRaidDB() + local td = rdb and rdb.textDesigner + return type(td) == "table" and td.migratedFromLegacy == true +end + +function DF:CleanupLegacyTextLayoutOverrides() + local prof = DF._realProfile or DF.db + if not prof or prof._legacyTextOverrideCleanupV1 then return end + local autoDb = prof.raidAutoProfiles + if type(autoDb) ~= "table" then return end + -- Don't strip — or set the guard — until the legacy text is safely in TD; an + -- unmigrated profile is retried on its next login / profile switch. + if not RaidTextMigrated() then return end + + local function stripLayout(layout) + local ov = layout and layout.overrides + if type(ov) ~= "table" then return end + local toStrip + for key in pairs(ov) do + if IsLegacyTextOverrideKey(key) then + toStrip = toStrip or {} + toStrip[#toStrip + 1] = key + end + end + if toStrip then for _, key in ipairs(toStrip) do ov[key] = nil end end + end + + for _, ctKey in ipairs({ "instanced", "openWorld" }) do + local ct = autoDb[ctKey] + if type(ct) == "table" and type(ct.profiles) == "table" then + for _, layout in pairs(ct.profiles) do stripLayout(layout) end + end + end + if type(autoDb.mythic) == "table" then stripLayout(autoDb.mythic.profile) end + + prof._legacyTextOverrideCleanupV1 = true +end + +-- Groups override keys by tab, returns { [tabId] = { tabLabel, keys = {} } }, unknownKeys +local function GroupOverridesByTab(overrides) + -- A table-valued override (e.g. "raidGroupVisible") and its flat indexed + -- siblings ("raidGroupVisible_5".."_8") can both be stored — the per-control + -- path writes the flat keys, while ExitEditing's diff safety-net writes the + -- whole table. They're the same setting, so when the whole-table form exists + -- we hide the redundant flat siblings (the grouped form reads cleaner). + local wholeTableKeys = {} + for key, val in pairs(overrides) do + if type(val) == "table" then wholeTableKeys[key] = true end + end + local groups = {} + local unknownKeys = {} + for key, _ in pairs(overrides) do + local base = key:match("^(.+)_%d+$") + if base and wholeTableKeys[base] then + -- redundant flat sibling of a whole-table override — skip + else + local tabId, tabLabel = GetOverrideTabId(key) + if tabId then + if not groups[tabId] then + groups[tabId] = { tabLabel = tabLabel, keys = {} } + end + tinsert(groups[tabId].keys, key) + else + tinsert(unknownKeys, key) + end + end + end + return groups, unknownKeys +end + +-- Canonical override count for display: everything that actually gets shown +-- (mapped groups + "Other"), deduped so redundant flat siblings of a whole-table +-- override aren't counted. Used by the row badge, the editing status line, and +-- /df debug overrides so all three agree. +local function CountDisplayedOverrides(overrides) + if not overrides then return 0 end + local groups, unknownKeys = GroupOverridesByTab(overrides) + local n = #unknownKeys + for _, group in pairs(groups) do n = n + #group.keys end + return n +end + +-- Deep value equality (used to skip leaves of a table override that match global). +local function OverrideDeepEqual(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not OverrideDeepEqual(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +-- Walk a table-valued override against its global counterpart and emit ONLY the +-- entries that differ. To keep a deep config (Aura Designer) readable we COLLAPSE +-- boring single-child wrapper levels into one breadcrumb header +-- (auras > HolyPriest > GuardianSpirit) and then list the actual changed leaves +-- indented beneath it — instead of repeating that long path on every leaf. +-- emit(indent, label, valueStr) -- valueStr == nil → a header/breadcrumb line +-- ovr = override table; glob = the global value (DF._realRaidDB[key]) and may be +-- nil/non-table (then everything in ovr differs). budget = {n=number} +-- (nil n = unlimited); maxDepth collapses deeper tables to "{…}". +local function WalkOverrideDiff(ovr, glob, prefix, indent, depth, maxDepth, budget, emit) + -- Friendly display key: TD elements show their name, not the array index. + local function disp(k) + local ov = ovr[k] + if type(ov) == "table" and ov.contentType and DF.TextDesigner and DF.TextDesigner.ElementDisplayName then + return DF.TextDesigner.ElementDisplayName(ov) or tostring(k) + end + return tostring(k) + end + + -- Collect only the children that differ from global. + local diff = {} + for k in pairs(ovr) do + local gv = (type(glob) == "table") and glob[k] or nil + if not OverrideDeepEqual(ovr[k], gv) then diff[#diff + 1] = k end + end + table.sort(diff, function(a, b) return tostring(a) < tostring(b) end) + + -- Single differing child that is itself a (non-colour) table → fold this level + -- into the breadcrumb and recurse, so wrapper levels don't each cost a line. + if #diff == 1 and depth < maxDepth then + local k = diff[1] + local ov = ovr[k] + if type(ov) == "table" and not (ov.r and ov.g and ov.b) then + local newPrefix = (prefix == "") and disp(k) or (prefix .. " > " .. disp(k)) + local gv = (type(glob) == "table") and glob[k] or nil + return WalkOverrideDiff(ov, gv, newPrefix, indent, depth + 1, maxDepth, budget, emit) + end + end + + -- Print the accumulated breadcrumb once as a header, then list children deeper. + if prefix ~= "" then + emit(indent, prefix, nil) + indent = indent .. " " + end + for _, k in ipairs(diff) do + if budget.n and budget.n <= 0 then emit(indent, "…", nil) return end + local ov = ovr[k] + local gv = (type(glob) == "table") and glob[k] or nil + if type(ov) == "table" and not (ov.r and ov.g and ov.b) then + if depth >= maxDepth then + emit(indent, disp(k), "{…}") + if budget.n then budget.n = budget.n - 1 end + else + -- This child becomes its own (possibly collapsed) sub-section. + WalkOverrideDiff(ov, gv, disp(k), indent, depth + 1, maxDepth, budget, emit) + end + else + local dv + if type(ov) == "table" then dv = string.format("(%.2f, %.2f, %.2f)", ov.r, ov.g, ov.b) + elseif type(ov) == "boolean" then dv = ov and "true" or "false" + else dv = tostring(ov) end + emit(indent, disp(k), dv) + if budget.n then budget.n = budget.n - 1 end + end + end +end + +-- Get a value from the real raid database, handling raid keys, table keys, and pinned keys. +-- Always reads the real table (DF._realRaidDB) — never the overlay proxy. +local function GetRaidValue(key) + local realRaid = DF._realRaidDB or (DF.db and DF.db.raid) + if not realRaid then return nil end + + -- Pinned frame key (stored at raid.pinnedFrames) + local setIndex, setting = ParsePinnedKey(key) + if setIndex and setting then + local pf = realRaid.pinnedFrames + local sets = pf and pf.sets + if sets and sets[setIndex] then + return sets[setIndex][setting] + end + return nil + end + + -- Table-based raid key + local tableName, index = ParseTableKey(key) + if tableName and index then + local tbl = realRaid[tableName] + if type(tbl) == "table" then + return tbl[index] + end + return nil + end + + -- Direct raid key + return realRaid[key] +end + +-- Make `dst` deeply equal `src` while PRESERVING the identity of dst and of every +-- nested table whose key/index aligns. Applying/restoring a whole-table override this +-- way keeps references captured elsewhere valid — the Text Designer page binds cards +-- and offset sliders straight to element objects (CreateSlider(..., elem, "offsetX")), +-- and the Preview binds to db.textDesigner. A plain `realRaid.textDesigner = newTable` +-- swaps that table (and its .elements array) out from under those bindings, so edits +-- land in the orphaned old table while the test frames read the new one — the TD +-- "test mode stops updating after a layout switch" freeze. +local function DeepReplaceInPlace(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + DeepReplaceInPlace(dst[k], v) + else + dst[k] = v + end + end + for k in pairs(dst) do + if src[k] == nil then dst[k] = nil end + end +end + +-- Set a value in the real raid database, handling raid keys, table keys, and pinned keys. +-- Always writes to the real table (DF._realRaidDB) — never the overlay proxy. +local function SetRaidValue(key, value) + local realRaid = DF._realRaidDB or (DF.db and DF.db.raid) + if not realRaid then return end + + -- Pinned frame key (stored at raid.pinnedFrames) + local setIndex, setting = ParsePinnedKey(key) + if setIndex and setting then + local pf = realRaid.pinnedFrames + local sets = pf and pf.sets + if sets and sets[setIndex] then + sets[setIndex][setting] = value + end + return + end + + -- Table-based raid key + local tableName, index = ParseTableKey(key) + if tableName and index then + local tbl = realRaid[tableName] + if type(tbl) == "table" then + tbl[index] = value + end + else + -- Whole-table override (textDesigner, auraDesigner, …): update CONTENTS in + -- place (deep) so the table object's identity is preserved and designer + -- bindings to it / its elements survive a layout switch. + local existing = realRaid[key] + if type(existing) == "table" and type(value) == "table" then + DeepReplaceInPlace(existing, value) + else + realRaid[key] = value + end + end +end + +-- Get a value from the global snapshot, handling all key types +-- Pinned keys are stored directly in the snapshot as "pinned.1.scale" etc +local function GetSnapshotValue(snapshot, key) + if not snapshot then return nil, false end + + -- Direct key match (works for pinned keys and direct raid keys) + if snapshot[key] ~= nil then + return snapshot[key], true + end + + -- Table-based key (e.g., "raidGroupVisible_1" -> snapshot["raidGroupVisible"][1]) + local tableName, index = ParseTableKey(key) + if tableName and index and type(snapshot[tableName]) == "table" then + return snapshot[tableName][index], true + end + + return nil, false +end + +-- Find a matching profile within a content type by raid size +-- Returns the first profile whose min-max range includes raidSize, or nil +local function FindMatchingProfile(contentKey, raidSize) + local autoDb = DF.db and DF.db.raidAutoProfiles + if not autoDb then return nil end + + local ct = autoDb[contentKey] + if not ct or not ct.profiles then return nil end + + for _, profile in ipairs(ct.profiles) do + if raidSize >= profile.min and raidSize <= profile.max then + DF:Debug("AUTOPROFILE", "FindMatchingProfile: matched \"%s\" for %s (size %d, range %d-%d)", profile.name or "?", contentKey, raidSize, profile.min, profile.max) + return profile + end + end + + DF:Debug("AUTOPROFILE", "FindMatchingProfile: no match for %s (size %d, %d profiles checked)", contentKey, raidSize, #ct.profiles) + return nil +end + +-- Initialize database defaults +function AutoProfilesUI:InitDefaults() + if not DF.db.raidAutoProfiles then + DF.db.raidAutoProfiles = { + enabled = false, + howItWorksCollapsed = false, + instanced = { profiles = {} }, + mythic = { profile = nil }, + openWorld = { profiles = {} } + } + end + -- Migration: add howItWorksCollapsed if missing from existing db + if DF.db.raidAutoProfiles.howItWorksCollapsed == nil then + DF.db.raidAutoProfiles.howItWorksCollapsed = false + end + + -- One-time cleanup: strip transient/never-override keys (raidLocked etc.) that + -- an earlier build's diff-scan may have captured into layout overrides. Such a + -- key forced the frames locked/unlocked whenever its layout activated (the + -- Lock/Unlock button read backwards). Runtime/edit paths now skip these keys, + -- but scrub the stored data so override counts are accurate too. + local ap = DF.db.raidAutoProfiles + if not ap.nonOverridableCleanupDone then + local function cleanOverrides(profile) + if profile and profile.overrides then + for key in pairs(NON_OVERRIDABLE_KEYS) do + profile.overrides[key] = nil + end + end + end + if ap.mythic then cleanOverrides(ap.mythic.profile) end + for _, ct in ipairs({ "instanced", "openWorld" }) do + if ap[ct] and ap[ct].profiles then + for _, p in ipairs(ap[ct].profiles) do cleanOverrides(p) end + end + end + ap.nonOverridableCleanupDone = true + end +end + +-- Get profiles for a content type +function AutoProfilesUI:GetProfiles(contentKey) + self:InitDefaults() + if contentKey == "mythic" then + local profile = DF.db.raidAutoProfiles.mythic.profile + return profile and {profile} or {} + else + return DF.db.raidAutoProfiles[contentKey].profiles or {} + end +end + +-- Check for range overlap within a content type +function AutoProfilesUI:CheckRangeOverlap(contentKey, min, max, excludeIndex) + local profiles = self:GetProfiles(contentKey) + for i, p in ipairs(profiles) do + if i ~= excludeIndex then + if min <= p.max and max >= p.min then + return p -- Returns the overlapping profile + end + end + end + return nil +end + +-- Create a profile +function AutoProfilesUI:CreateProfile(contentKey, name, min, max) + self:InitDefaults() + DF:Debug("AUTOPROFILE", "CreateProfile: contentKey=%s name=\"%s\" min=%s max=%s", contentKey, name or "?", tostring(min), tostring(max)) + + if contentKey == "mythic" then + DF.db.raidAutoProfiles.mythic.profile = { + name = name or "Mythic Setup", + overrides = {} + } + DF:Debug("AUTOPROFILE", "CreateProfile: mythic layout created") + return true + end + + -- Check for name conflict + local profiles = DF.db.raidAutoProfiles[contentKey].profiles + for _, p in ipairs(profiles) do + if p.name:lower() == name:lower() then + DF:DebugWarn("AUTOPROFILE", "CreateProfile: name conflict with \"%s\"", name) + return false, L["Name already exists"] + end + end + + -- Check for range overlap + local overlap = self:CheckRangeOverlap(contentKey, min, max) + if overlap then + return false, format(L["Overlaps with \"%s\""], overlap.name) + end + + -- Create the profile + table.insert(profiles, { + name = name, + min = min, + max = max, + overrides = {} + }) + + -- Sort by min range + table.sort(profiles, function(a, b) return a.min < b.min end) + + return true +end + +-- Delete a profile +function AutoProfilesUI:DeleteProfile(contentKey, index) + self:InitDefaults() + + -- Capture a reference before deletion to check if it was the active runtime profile + local deletedProfile + if contentKey == "mythic" then + deletedProfile = DF.db.raidAutoProfiles.mythic.profile + DF.db.raidAutoProfiles.mythic.profile = nil + else + local profiles = DF.db.raidAutoProfiles[contentKey].profiles + if profiles[index] then + deletedProfile = profiles[index] + table.remove(profiles, index) + else + DF:DebugWarn("AUTOPROFILE", "DeleteProfile: index %s not found in %s", tostring(index), contentKey) + return false + end + end + + DF:Debug("AUTOPROFILE", "DeleteProfile: removed \"%s\" from %s", deletedProfile and deletedProfile.name or "?", contentKey) + + -- If the deleted profile was the active runtime profile, deactivate it + if deletedProfile and self.activeRuntimeProfile == deletedProfile then + DF:Debug("AUTOPROFILE", "DeleteProfile: deleted layout was active, deactivating overlay") + DF.raidOverrides = nil + self.activeRuntimeProfile = nil + self.activeRuntimeContentKey = nil + if DF.FullProfileRefresh then + DF:FullProfileRefresh() + end + DF:Say(L["Auto-profile deactivated (profile deleted)"]) + end + + return true +end + +-- Update profile range +function AutoProfilesUI:UpdateProfileRange(contentKey, index, newMin, newMax) + self:InitDefaults() + DF:Debug("AUTOPROFILE", "UpdateProfileRange: %s index=%s newMin=%d newMax=%d", contentKey, tostring(index), newMin, newMax) + + if contentKey == "mythic" then + return false, L["Mythic has fixed range"] + end + + local profiles = DF.db.raidAutoProfiles[contentKey].profiles + if not profiles[index] then + return false, L["Profile not found"] + end + + -- Check for overlap (excluding current profile) + local overlap = self:CheckRangeOverlap(contentKey, newMin, newMax, index) + if overlap then + DF:DebugWarn("AUTOPROFILE", "UpdateProfileRange: overlap with \"%s\" (%d-%d)", overlap.name, overlap.min, overlap.max) + return false, format(L["Overlaps with \"%s\""], overlap.name) + end + + local oldMin, oldMax = profiles[index].min, profiles[index].max + profiles[index].min = newMin + profiles[index].max = newMax + DF:Debug("AUTOPROFILE", "UpdateProfileRange: \"%s\" range %d-%d -> %d-%d", profiles[index].name or "?", oldMin, oldMax, newMin, newMax) + + -- Re-sort + table.sort(profiles, function(a, b) return a.min < b.min end) + + return true +end + +-- ============================================================ +-- EDIT MODE STATE & FUNCTIONS +-- ============================================================ + +-- Runtime state (not persisted) +AutoProfilesUI.editingProfile = nil +AutoProfilesUI.editingContentType = nil +AutoProfilesUI.editingProfileIndex = nil +AutoProfilesUI.globalSnapshot = nil -- Snapshot of true global values during editing + +-- Runtime auto-profile state (not persisted — rebuilt on login/reload via events) +AutoProfilesUI.activeRuntimeProfile = nil -- Currently applied profile reference +AutoProfilesUI.activeRuntimeContentKey = nil -- "mythic"/"instanced"/"openWorld" +AutoProfilesUI.pendingAutoProfileEval = false -- Queued evaluation during combat + +function AutoProfilesUI:IsEditing() + return self.editingProfile ~= nil +end + +-- True when an auto layout is currently driving the live raid frames. Used to +-- steer base-position edits (the toolbar Unlock + /df raidunlock) toward the +-- active layout's own Unlock button so users don't move the base by accident. +function AutoProfilesUI:IsLayoutActive() + return self.activeRuntimeProfile ~= nil +end + +function AutoProfilesUI:GetActiveLayoutName() + return self.activeRuntimeProfile and self.activeRuntimeProfile.name or nil +end + +function AutoProfilesUI:EnterEditing(contentType, profileIndex) + DF:Debug("AUTOPROFILE", "EnterEditing: contentType=%s profileIndex=%s", contentType, tostring(profileIndex)) + + -- Enforcement guard (mirror of the row's blockEdit greying in CreateProfileRow). + -- Editing writes the layout's settings into the live raid db for preview, so + -- the real raid frames only stay correct when the layout being edited is the + -- one actually driving them. Refuse when editing would disturb live frames: + -- a different layout is running, OR you're in a raid group with no layout + -- running. Done here (not just on the button) because the button's disabled + -- state is computed at page-draw time and can go stale if raid state changes. + -- Resolved BEFORE the overlay clear below nils activeRuntimeProfile. + do + local apDb = DF.db.raidAutoProfiles + local requested + if contentType == "mythic" then + requested = apDb.mythic and apDb.mythic.profile + else + local profiles = apDb[contentType] and apDb[contentType].profiles + requested = profiles and profiles[profileIndex] + end + if requested and self.activeRuntimeProfile ~= requested + and (self.activeRuntimeProfile ~= nil or IsInRaid()) then + DF:DebugWarn("AUTOPROFILE", "EnterEditing: refused — only the active layout is editable in a raid group") + -- The row's Edit button greys out with a tooltip, but its disabled + -- state is drawn at page-build time and can be STALE (e.g. you + -- joined a raid with the window open). A silent refusal on a + -- clickable-looking button reads as "broken" — say why in chat and + -- redraw the page so the button greys correctly. + local msg + if self.activeRuntimeProfile then + msg = (L["Only the active layout can be edited\nwhile auto layouts are running."] or ""):gsub("\n", " ") + else + msg = L["While in a raid group you can only edit the active layout. Leave the raid group to edit other layouts."] + end + DF:Say((msg or "")) + local GUI = DF.GUI + if GUI and GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + return false, "Blocked: live raid frames" + end + end + + -- Clear overlay so snapshot captures true globals (no RemoveRuntimeProfile + -- needed — real table was never mutated) + if self.activeRuntimeProfile then + DF:Debug("AUTOPROFILE", "EnterEditing: clearing active runtime overlay before snapshot") + DF.raidOverrides = nil + self.activeRuntimeProfile = nil + self.activeRuntimeContentKey = nil + end + + local autoDb = DF.db.raidAutoProfiles + + if contentType == "mythic" then + self.editingProfile = autoDb.mythic.profile + self.editingProfileIndex = nil + else + local profiles = autoDb[contentType] and autoDb[contentType].profiles + if profiles and profiles[profileIndex] then + self.editingProfile = profiles[profileIndex] + self.editingProfileIndex = profileIndex + else + DF:DebugWarn("AUTOPROFILE", "EnterEditing: profile not found at index %s", tostring(profileIndex)) + return false, "Profile not found" + end + end + + self.editingContentType = contentType + DF:Debug("AUTOPROFILE", "EnterEditing: editing \"%s\" (%s)", self.editingProfile.name or "?", contentType) + + -- Snapshot ALL true global values before anything gets modified + -- This lets controls freely write to DF._realRaidDB for live preview while + -- SetProfileSetting/GetGlobalValue always know the original globals + self.globalSnapshot = {} + for key, value in pairs(DF._realRaidDB) do + self.globalSnapshot[key] = DeepCopyValue(value) + end + + -- Snapshot pinned frames overridable settings (stored as "pinned.N.setting" keys) + -- Pinned frames live at raid.pinnedFrames + -- IMPORTANT: Iterate PINNED_OVERRIDABLE keys rather than set keys to ensure + -- we capture ALL overridable settings, even ones that are nil due to missing migration. + -- If a key is nil, backfill a default so both the snapshot and set are consistent. + -- Only `enabled` is overridable (PINNED_OVERRIDABLE = {enabled}), and the loop + -- below reads PINNED_DEFAULTS only for the keys it iterates — so this table needs + -- just `enabled`. (It previously listed ~13 dead keys, incl. a keepOfflinePlayers + -- default that contradicted the real Config/Options default.) + local PINNED_DEFAULTS = { + enabled = false, + } + local pinnedFrames = DF._realRaidDB and DF._realRaidDB.pinnedFrames + if pinnedFrames and pinnedFrames.sets then + for setIdx, set in pairs(pinnedFrames.sets) do + for setting, _ in pairs(PINNED_OVERRIDABLE) do + local value = set[setting] + -- Backfill nil values with defaults so snapshot always has a baseline + if value == nil then + value = PINNED_DEFAULTS[setting] + set[setting] = DeepCopyValue(value) + value = set[setting] + end + local snapKey = "pinned." .. setIdx .. "." .. setting + self.globalSnapshot[snapKey] = DeepCopyValue(value) + end + end + end + + -- Persist a lightweight recovery flag so a crash/disconnect during editing + -- can be detected on next login and contaminated values reset to defaults + local recoveryKeys = {} + for key in pairs(self.globalSnapshot) do + recoveryKeys[#recoveryKeys + 1] = key + end + DF.db.raidAutoEditingRecovery = { + contentType = contentType, + profileIndex = profileIndex, + snapshotKeys = recoveryKeys, + } + + -- Apply existing overrides to db.raid for live preview. + -- Skip "pinnedFrames" as a direct key — it is a stale artifact from an older + -- bug where dragging a pinned frame during editing caused the entire pinnedFrames + -- table (including position) to be saved as a profile override. Pinned frame + -- settings are managed via "pinned.N.setting" keys; applying the raw table would + -- overwrite _realRaidDB.pinnedFrames and move the frame to the stored position. + local overrideCount = 0 + if self.editingProfile.overrides then + for key, value in pairs(self.editingProfile.overrides) do + if key == "pinnedFrames" then + DF:DebugWarn("AUTOPROFILE", "EnterEditing: skipping stale pinnedFrames direct override key") + elseif not IsKeyOverridable(key) then + -- transient/never-override key (e.g. a stale raidLocked) — don't + -- apply it to the live preview (ExitEditing self-heals it away). + DF:DebugWarn("AUTOPROFILE", "EnterEditing: skipping non-overridable override \"%s\"", key) + else + SetRaidValue(key, DeepCopyValue(value)) + overrideCount = overrideCount + 1 + end + end + end + DF:Debug("AUTOPROFILE", "EnterEditing: applied %d overrides for live preview", overrideCount) + + -- Refresh pinned frames to show overridden settings in live preview. + -- * In an actual raid: re-apply each set's enabled + layout to the LIVE + -- pinned frames (their methods key off live IsInRaid()). + -- * In raid TEST mode: the live frames stay party-mode and must not be + -- touched (that would apply the raid layout to the party set / leak the + -- party header). Instead just re-run the raid test-mode pinned frames so + -- they re-size to the layout's overridden frame width via their own + -- mode-explicit path (ApplyPlayerTestLayout reads the raid DB). + if DF.PinnedFrames and IsInRaid() then + local pf = DF.db.raid and DF.db.raid.pinnedFrames + for i = 1, DF.PinnedFrames.MAX_SETS do + local setEnabled = pf and pf.sets and pf.sets[i] and pf.sets[i].enabled + DF.PinnedFrames:SetEnabled(i, setEnabled or false) + DF.PinnedFrames:ApplyLayoutSettings(i) + DF.PinnedFrames:ResizeContainer(i) + DF.PinnedFrames:UpdateHeaderNameList(i) + end + elseif DF.PinnedFrames and DF.raidTestMode then + DF.PinnedFrames:ExitTestMode() + DF.PinnedFrames:EnterTestMode() + end + + -- Refresh test mode frames so they display override values. + -- PositionTestRaidContainer MUST run before RefreshTestFramesWithLayout: + -- the overrides written above may include a raidAnchorX/Y for this profile, + -- and frames are positioned relative to testRaidContainer's BOTTOMLEFT. + -- If the container isn't moved first, frames land at the wrong screen position. + -- (#906) + if DF.raidTestMode then + if DF.PositionTestRaidContainer then + DF:PositionTestRaidContainer() + end + -- If the mover is visible (frames unlocked), sync its position and scale + -- to match the freshly-positioned test container. The profile overrides + -- applied above may include a different raidAnchorX/Y or frameScale, so + -- the mover needs to follow the container or it will appear offset. + if DF.raidMoverFrame and DF.raidMoverFrame:IsShown() then + local moverDb = DF:GetRaidDB() + local moverScale = moverDb.frameScale or 1.0 + DF.raidMoverFrame:SetScale(moverScale) + DF.raidMoverFrame:ClearAllPoints() + DF.raidMoverFrame:SetPoint("CENTER", UIParent, "CENTER", (moverDb.raidAnchorX or 0) / moverScale, (moverDb.raidAnchorY or 0) / moverScale) + end + -- Refresh position panel inputs to reflect the newly-applied override anchor/scale + if DF.positionPanel and DF.positionPanel:IsShown() and DF.UpdatePositionPanel then + DF:UpdatePositionPanel() + end + if DF.RefreshTestFramesWithLayout then + DF:RefreshTestFramesWithLayout() + end + end + + -- Refresh the GUI to show editing banner and disable Auto Profiles tab + self:RefreshEditingUI() + + -- Switch to a settings tab (e.g., Layout/Frame) + -- Suppress sidebar hint dismissal for this initial SelectTab call + self.suppressHintDismiss = true + local GUI = DF.GUI + -- The page build cache is keyed on mode only, not the active layout, so other + -- tabs (Aura Designer / Text Designer frame previews especially) would re-show + -- the previous layout's geometry. Invalidate every page so each rebuilds for + -- this layout's frame size on next view. + if GUI and GUI.InvalidateAllPages then GUI:InvalidateAllPages() end + if GUI and GUI.SelectTab then + GUI.SelectTab("general_frame") + end + self.suppressHintDismiss = false + + -- Show sidebar onboarding hint (dismissed on first user tab click) + self:ShowSidebarHint() + + -- Show override stars on tabs that have overridden settings + self:RefreshTabOverrideStars() + + return true +end + +-- Deep-compare two values (recursive for nested tables) +local function DeepCompare(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do + if not DeepCompare(v, b[k]) then return false end + end + for k in pairs(b) do + if a[k] == nil then return false end + end + return true +end + +function AutoProfilesUI:ExitEditing(skipUIUpdates) + DF:Debug("AUTOPROFILE", "ExitEditing: profile=\"%s\" skipUIUpdates=%s", self.editingProfile and self.editingProfile.name or "?", tostring(skipUIUpdates)) + + -- Diff safety net: before restoring globals, scan live values vs snapshot + -- to catch any overrides that weren't explicitly tracked by controls + if self.editingProfile and self.globalSnapshot then + if not self.editingProfile.overrides then + self.editingProfile.overrides = {} + end + local overrides = self.editingProfile.overrides + local autoStored, autoCleaned = 0, 0 + + for key, snapshotVal in pairs(self.globalSnapshot) do + -- Skip "pinnedFrames" as a direct key. Pinned frame settings are tracked + -- via "pinned.N.setting" keys (PINNED_OVERRIDABLE). Comparing the whole + -- pinnedFrames table would cause position changes (from dragging during + -- editing) to be stored as a top-level profile override, which then + -- overwrites _realRaidDB.pinnedFrames and moves the frame on next activation. + if key == "pinnedFrames" then + -- Clean up any stale pinnedFrames override that may already exist + if overrides[key] ~= nil then + overrides[key] = nil + autoCleaned = autoCleaned + 1 + DF:DebugWarn("AUTOPROFILE", "ExitEditing: removed stale pinnedFrames direct override key") + end + elseif not IsKeyOverridable(key) then + -- Transient/never-override key (e.g. raidLocked) — never store it, + -- and self-heal any override an earlier build captured. + if overrides[key] ~= nil then + overrides[key] = nil + autoCleaned = autoCleaned + 1 + DF:DebugWarn("AUTOPROFILE", "ExitEditing: removed non-overridable override \"%s\"", key) + end + else + local currentVal = GetRaidValue(key) + local matches = DeepCompare(snapshotVal, currentVal) + + -- If this table-valued setting is already tracked via flat indexed + -- sub-overrides ("key_5".."key_8", e.g. raidGroupVisible written by the + -- group-visibility checkboxes), don't ALSO store the whole table here — + -- that double-stores the same setting and collides in + -- ApplyRuntimeProfile. The flat sub-keys are canonical. + local hasFlatSiblings = false + if type(currentVal) == "table" then + for ok in pairs(overrides) do + if ok:match("^(.+)_%d+$") == key then hasFlatSiblings = true break end + end + end + + if hasFlatSiblings then + -- Drop any stale whole-table override (older saves stored it) so + -- existing dirty profiles self-heal on next exit-editing. + if overrides[key] ~= nil then + overrides[key] = nil + autoCleaned = autoCleaned + 1 + DF:DebugWarn("AUTOPROFILE", "ExitEditing: removed redundant whole-table override \"%s\" (tracked via flat keys)", key) + end + elseif not matches then + -- Only deep-copy when override is new or has actually changed + if overrides[key] == nil or not DeepCompare(overrides[key], currentVal) then + overrides[key] = DeepCopyValue(currentVal) + autoStored = autoStored + 1 + end + elseif matches and overrides[key] ~= nil + and key ~= "textDesignerPreset" and key ~= "auraDesignerPreset" then + -- Designer preset refs are exempt from the equal→remove + -- cleanup: SetModeDesignerPreset deliberately stamps the + -- override even when the chosen name EQUALS the global's + -- (explicit pin vs Inherit is presence-based). Removing it + -- here silently reverted the layout to "Inherit (Global)" + -- on exit. + overrides[key] = nil + autoCleaned = autoCleaned + 1 + end + end + end + + -- Remove orphan keys created during editing that aren't tracked overrides + local orphansRemoved = 0 + for key in pairs(DF._realRaidDB) do + if self.globalSnapshot[key] == nil and not overrides[key] then + DF._realRaidDB[key] = nil + orphansRemoved = orphansRemoved + 1 + end + end + + local totalOverrides = 0 + for _ in pairs(overrides) do totalOverrides = totalOverrides + 1 end + DF:Debug("AUTOPROFILE", "ExitEditing: diff scan — autoStored=%d autoCleaned=%d orphansRemoved=%d totalOverrides=%d", autoStored, autoCleaned, orphansRemoved, totalOverrides) + end + + -- Restore all modified values back to their true globals + -- SetRaidValue handles raid keys, table keys, and pinned keys + if self.globalSnapshot then + for key, originalValue in pairs(self.globalSnapshot) do + SetRaidValue(key, DeepCopyValue(originalValue)) + end + end + + self.editingProfile = nil + self.editingContentType = nil + self.editingProfileIndex = nil + self.globalSnapshot = nil + + -- Clear crash recovery flag — editing exited cleanly + if DF.db then + DF.db.raidAutoEditingRecovery = nil + end + + -- Hide sidebar hint if still showing + self:HideSidebarHint() + + -- Clear override stars (they'll refresh after EvaluateAndApply if a runtime profile re-applies) + self:RefreshTabOverrideStars() + + -- Skip UI updates when GUI is closing (UI will reset on next open anyway) + if skipUIUpdates then return end + + -- Re-evaluate auto-profiles BEFORE UpdateAll so any re-applied overlay is in + -- place when frames read settings — otherwise UpdateAll would read globals, + -- then the delayed evaluator would re-apply the overlay causing a flicker. + -- EvaluateAndApply internally checks InCombatLockdown so this is combat-safe. + AutoProfilesUI:EvaluateAndApply() + + -- Refresh frames to show global settings again + if DF.UpdateAll then DF:UpdateAll() end + + -- Test mode needs a full layout refresh so frames re-read restored global values + -- UpdateAll() only calls UpdateRaidTestFrames() which skips ApplyTestFrameLayout(), + -- so frame sizes, textures, fonts etc. would remain stuck on overridden values. + -- PositionTestRaidContainer MUST run before RefreshTestFramesWithLayout so the + -- container is at the restored base anchor before frames are positioned against it. + -- (#906) + if DF.raidTestMode then + if DF.PositionTestRaidContainer then + DF:PositionTestRaidContainer() + end + -- Sync mover position/scale to match the restored base anchor. + -- EvaluateAndApply above may have re-applied a runtime overlay, so + -- DF:GetRaidDB() here reflects whichever values are now authoritative. + if DF.raidMoverFrame and DF.raidMoverFrame:IsShown() then + local moverDb = DF:GetRaidDB() + local moverScale = moverDb.frameScale or 1.0 + DF.raidMoverFrame:SetScale(moverScale) + DF.raidMoverFrame:ClearAllPoints() + DF.raidMoverFrame:SetPoint("CENTER", UIParent, "CENTER", (moverDb.raidAnchorX or 0) / moverScale, (moverDb.raidAnchorY or 0) / moverScale) + end + -- Refresh position panel inputs to reflect the restored anchor/scale values. + -- EvaluateAndApply above may have re-applied a runtime overlay, so + -- DF:GetRaidDB() is already authoritative at this point. + if DF.positionPanel and DF.positionPanel:IsShown() and DF.UpdatePositionPanel then + DF:UpdatePositionPanel() + end + if DF.RefreshTestFramesWithLayout then + DF:RefreshTestFramesWithLayout() + end + elseif DF.testMode and DF.RefreshTestFramesWithLayout then + DF:RefreshTestFramesWithLayout() + end + + -- Refresh pinned frames to show global settings again (mirror of EnterEditing): + -- live raid re-applies to the real frames; raid test mode just re-runs the test + -- frames so they re-size back to the global frame width. + if DF.PinnedFrames and IsInRaid() then + local pf = DF.db.raid and DF.db.raid.pinnedFrames + for i = 1, DF.PinnedFrames.MAX_SETS do + -- Restore enabled state first (hides containers if globally disabled) + local setEnabled = pf and pf.sets and pf.sets[i] and pf.sets[i].enabled + DF.PinnedFrames:SetEnabled(i, setEnabled or false) + DF.PinnedFrames:ApplyLayoutSettings(i) + DF.PinnedFrames:ResizeContainer(i) + DF.PinnedFrames:UpdateHeaderNameList(i) + end + elseif DF.PinnedFrames and DF.raidTestMode then + DF.PinnedFrames:ExitTestMode() + DF.PinnedFrames:EnterTestMode() + end + + -- Refresh UI to hide banner and re-enable Auto Profiles tab + self:RefreshEditingUI() + + -- Switch back to Auto Profiles tab + local GUI = DF.GUI + -- Active layout reverted to global — invalidate page caches so tabs (and their + -- frame previews) rebuild at the global frame size on next view. + if GUI and GUI.InvalidateAllPages then GUI:InvalidateAllPages() end + if GUI and GUI.SelectTab then + GUI.SelectTab("profiles_auto") + end +end + +function AutoProfilesUI:GetEditingInfo() + if not self:IsEditing() then return nil end + + local profile = self.editingProfile + local contentType = self.editingContentType + + -- Get content type display name + local contentName = "Unknown" + for _, ct in ipairs(CONTENT_TYPES) do + if ct.key == contentType then + contentName = ct.title + break + end + end + + -- Get range display + local rangeText + if contentType == "mythic" then + rangeText = L["20 players (fixed)"] + else + rangeText = format(L["%d-%d players"], profile.min, profile.max) + end + + -- Count overrides + local overrideCount = 0 + if profile.overrides then + for _ in pairs(profile.overrides) do + overrideCount = overrideCount + 1 + end + end + + return { + name = profile.name or L["Unnamed"], + contentType = contentType, + contentName = contentName, + rangeText = rangeText, + overrideCount = overrideCount, + } +end + +-- ============================================================ +-- OVERRIDE SYSTEM FUNCTIONS +-- ============================================================ + +function AutoProfilesUI:SetProfileSetting(key, value) + if not self.editingProfile then return false end + + -- Non-overridable pinned setting (everything except `enabled` post-decouple): + -- never store it as a layout override. Strip any existing one and bail so the + -- control's plain global write is the only effect. + if not IsKeyOverridable(key) then + if self.editingProfile.overrides then + self.editingProfile.overrides[key] = nil + end + return false + end + + -- Ensure overrides table exists + if not self.editingProfile.overrides then + self.editingProfile.overrides = {} + end + + -- Get the true global from snapshot (db.raid has been modified by the control already) + local globalValue, found = GetSnapshotValue(self.globalSnapshot, key) + if not found then + globalValue = GetRaidValue(key) + end + + -- Compare values (recursive deep compare handles nested tables like colors) + local valuesMatch = DeepCompare(value, globalValue) + + if valuesMatch then + -- Same as global, remove override + self.editingProfile.overrides[key] = nil + DF:Debug("AUTOPROFILE", "SetProfileSetting: %s matches global, override removed", key) + else + -- Different from global, store override (deep copy to avoid reference issues) + self.editingProfile.overrides[key] = DeepCopyValue(value) + DF:Debug("AUTOPROFILE", "SetProfileSetting: %s overridden (value=%s)", key, tostring(value)) + end + + -- Refresh tab stars so they update live as settings change + self:RefreshTabOverrideStars() + + return true +end + +-- Reset a setting to global (remove override) +function AutoProfilesUI:ResetProfileSetting(key) + if not self.editingProfile then return false end + DF:Debug("AUTOPROFILE", "ResetProfileSetting: %s — restoring to global", key) + + if self.editingProfile.overrides then + self.editingProfile.overrides[key] = nil + end + + -- Restore the actual db value to the true global (from snapshot) + local globalValue, found = GetSnapshotValue(self.globalSnapshot, key) + if not found then + globalValue = GetRaidValue(key) + end + + -- Deep copy to avoid mutating the snapshot + SetRaidValue(key, DeepCopyValue(globalValue)) + + -- Refresh tab stars so they update live as overrides are removed + self:RefreshTabOverrideStars() + + return true +end + +-- Get the global value for a setting (for display purposes) +-- Returns a deep copy to prevent callers from mutating the snapshot +function AutoProfilesUI:GetGlobalValue(key) + -- When editing, return the true global from snapshot (db.raid may have overridden values) + local snapshotVal, found = GetSnapshotValue(self.globalSnapshot, key) + if found then + return DeepCopyValue(snapshotVal) + end + return GetRaidValue(key) +end + +-- Check if a setting is currently overridden +function AutoProfilesUI:IsSettingOverridden(key) + if not IsKeyOverridable(key) then return false end + if not self.editingProfile or not self.editingProfile.overrides then + return false + end + return self.editingProfile.overrides[key] ~= nil +end + +-- Is a pinned-frame SETTING (bare name, e.g. "enabled" / "scale") overridable +-- per auto layout? Post-decouple only `enabled` is. The pinned options page uses +-- this to decide whether to show any override UI (star / reset / Global: text) +-- for a control — non-overridable controls show none, making it clear they're +-- global/independent of auto layouts. +function AutoProfilesUI:IsPinnedSettingOverridable(setting) + return PINNED_OVERRIDABLE[setting] == true +end + +-- Get active profile based on current content/raid size (for runtime, not editing) +-- Returns: profile, contentKey (or nil, nil if no match) +function AutoProfilesUI:GetActiveProfile() + local autoDb = DF.db.raidAutoProfiles + if not autoDb or not autoDb.enabled then + DF:Debug("AUTOPROFILE", "GetActiveProfile: disabled or no autoDb") + return nil + end + + -- Must be in a raid group for auto-profiles to apply + if not IsInRaid() then + DF:Debug("AUTOPROFILE", "GetActiveProfile: not in raid") + return nil + end + + -- Use the existing content type detection from Core.lua + local contentType = DF:GetContentType() + if not contentType then + DF:Debug("AUTOPROFILE", "GetActiveProfile: no content type detected") + return nil + end + + local raidSize = GetNumGroupMembers() + DF:Debug("AUTOPROFILE", "GetActiveProfile: contentType=%s raidSize=%d", contentType, raidSize) + + -- Mythic: single profile, no range check needed + if contentType == "mythic" then + local mythicProfile = autoDb.mythic and autoDb.mythic.profile + if mythicProfile then + DF:Debug("AUTOPROFILE", "GetActiveProfile: matched mythic layout \"%s\"", mythicProfile.name or "?") + return mythicProfile, "mythic" + end + DF:Debug("AUTOPROFILE", "GetActiveProfile: mythic content but no layout configured") + return nil + end + + -- Map content type to auto-profile key + -- "battleground" falls under "instanced" (the "Instanced / PvP" category) + local profileKey + if contentType == "instanced" or contentType == "battleground" then + profileKey = "instanced" + elseif contentType == "openWorld" then + profileKey = "openWorld" + else + DF:Debug("AUTOPROFILE", "GetActiveProfile: unmapped content type \"%s\", no layout", contentType) + return nil + end + + -- Find matching profile by raid size within the content type + local profile = FindMatchingProfile(profileKey, raidSize) + if profile then + return profile, profileKey + end + + DF:Debug("AUTOPROFILE", "GetActiveProfile: no matching range for %s (size %d)", profileKey, raidSize) + return nil +end + +-- ============================================================ +-- RUNTIME PROFILE APPLICATION +-- Applies/removes auto-profile overrides at runtime based on +-- content type and raid size changes +-- ============================================================ + +-- Get a display name for a content key +local function GetContentDisplayName(contentKey) + if contentKey == "mythic" then return L["Mythic"] + elseif contentKey == "instanced" then return L["Instanced/PvP"] + elseif contentKey == "openWorld" then return L["Open World"] + end + return contentKey or L["Unknown"] +end + +-- Apply a profile's overrides as a read-through overlay (does NOT mutate the real raid table) +function AutoProfilesUI:ApplyRuntimeProfile(profile, contentKey) + if not profile or not profile.overrides then return end + DF:Debug("AUTOPROFILE", "ApplyRuntimeProfile: \"%s\" (%s)", profile.name or "?", contentKey) + + -- Group nested overrides by parent table so multiple overrides on the same + -- parent (e.g. raidGroupVisible_1 and raidGroupVisible_3) share one copy + local tableGroups = {} -- { [parentTable] = { [index] = value, ... } } + local pinnedGroups = {} -- { [setIndex] = { [setting] = value, ... } } + local simpleKeys = {} -- { [key] = value } + + for key, value in pairs(profile.overrides) do + -- Defensive: never apply a transient/never-override key (e.g. a raidLocked + -- captured by an earlier build) to the live overlay, or the layout would + -- force the frames locked/unlocked whenever it activates. + if not IsKeyOverridable(key) then + -- skip + else + local setIndex, setting = ParsePinnedKey(key) + if setIndex and setting then + if not pinnedGroups[setIndex] then pinnedGroups[setIndex] = {} end + pinnedGroups[setIndex][setting] = value + else + local tableName, index = ParseTableKey(key) + if tableName and index then + if not tableGroups[tableName] then tableGroups[tableName] = {} end + tableGroups[tableName][index] = value + else + simpleKeys[key] = value + end + end + end + end + + -- Build the overlay table + local overlay = {} + + -- Simple keys: direct copy + for key, value in pairs(simpleKeys) do + overlay[key] = DeepCopyValue(value) + end + + -- Table keys: deep-copy parent from real table, apply overrides + for tableName, indices in pairs(tableGroups) do + local parent = DeepCopyValue(DF._realRaidDB[tableName]) + if type(parent) ~= "table" then parent = {} end + for index, value in pairs(indices) do + parent[index] = DeepCopyValue(value) + end + overlay[tableName] = parent + end + + -- Pinned keys: build a LIVE VIEW of the real pinnedFrames, not a deep copy. + -- Post-decouple the only pinned override is per-set `enabled`, so every other + -- pinned setting must read through to the live global (edits reflect + -- immediately, and there's nothing to go stale). Each set that has an + -- `enabled` override becomes a thin proxy that returns the overridden enabled + -- but reads every other field live from the real set; sets with no override + -- are the real table by reference. The whole pinnedFrames table is itself a + -- live view (top-level fields read through to real). + if next(pinnedGroups) then + local realPF = DF._realRaidDB.pinnedFrames or {} + local realSets = type(realPF.sets) == "table" and realPF.sets or {} + local viewSets = {} + for setIdx, realSet in pairs(realSets) do + local ov = pinnedGroups[setIdx] + if ov and ov.enabled ~= nil then + -- __newindex: WRITES must reach the real set, or every direct + -- field write from the Options page (width/height overrides, + -- preset refs, border seeding, label, roster, position…) + -- rawsets onto this throwaway view and silently reverts on the + -- next layout switch / reload. `enabled` itself is exempt by + -- construction: it is rawset on the view, so __newindex never + -- fires for it — the runtime override value stays view-local + -- while HandleRuntimeWrite persists it to override/global. + viewSets[setIdx] = setmetatable( + { enabled = ov.enabled and true or false }, + { + __index = realSet, + __newindex = function(_, key, value) + realSet[key] = value + end, + }) + else + viewSets[setIdx] = realSet -- live reference + end + end + -- Top-level fields (disableInPvP, …) must equally write through to the + -- real pinnedFrames table; `sets` is rawset above so __newindex never + -- fires for it. + overlay.pinnedFrames = setmetatable({ sets = viewSets }, { + __index = realPF, + __newindex = function(_, key, value) + realPF[key] = value + end, + }) + end + + -- Activate the overlay (proxy reads this automatically) + DF.raidOverrides = overlay + + -- Count overlay keys for debug + local overlayCount = 0 + for _ in pairs(overlay) do overlayCount = overlayCount + 1 end + local totalOverrides = 0 + for _ in pairs(profile.overrides) do totalOverrides = totalOverrides + 1 end + DF:Debug("AUTOPROFILE", "ApplyRuntimeProfile: overlay active — %d overrides, %d overlay keys", totalOverrides, overlayCount) + + -- Store active state + self.activeRuntimeProfile = profile + self.activeRuntimeContentKey = contentKey + + -- Refresh all frames to reflect new settings + if DF.FullProfileRefresh then + DF:FullProfileRefresh() + end + + -- Chat notification + local raidSize = GetNumGroupMembers() + local contentName = GetContentDisplayName(contentKey) + DF:Say(format(L["Auto-profile \"%s\" activated (%s, %d players)"], profile.name or L["Unnamed"], contentName, raidSize)) + + -- Update tab override stars + self:RefreshTabOverrideStars() + + -- A layout going active greys the toolbar Unlock (it gates on IsLayoutActive); + -- refresh it if the GUI is open so the button state isn't stale. + if DF.GUI and DF.GUI.UpdateLockButtonState then DF.GUI.UpdateLockButtonState() end +end + +-- Remove the active runtime profile overlay +function AutoProfilesUI:RemoveRuntimeProfile() + if not self.activeRuntimeProfile then return end + DF:Debug("AUTOPROFILE", "RemoveRuntimeProfile: deactivating \"%s\" (%s)", self.activeRuntimeProfile.name or "?", self.activeRuntimeContentKey or "?") + + -- Clear overlay FIRST so any subsequent refresh reads global settings + DF.raidOverrides = nil + + -- Clear runtime state + self.activeRuntimeProfile = nil + self.activeRuntimeContentKey = nil + + -- Snap frames to base settings immediately so they are never in a + -- partially-configured state between overlay clear and the caller's refresh + if not InCombatLockdown() and DF.FullProfileRefresh then + DF:FullProfileRefresh() + end + + DF:Say(L["Auto-profile deactivated, using global settings"]) + self:RefreshTabOverrideStars() + + -- A layout deactivating must re-enable the toolbar Unlock — otherwise it stays + -- greyed and its click wrongly steers to the Auto Layouts page (stuck state). + if DF.GUI and DF.GUI.UpdateLockButtonState then DF.GUI.UpdateLockButtonState() end +end + +-- Write a raid position straight into the ACTIVE layout's override + live overlay, +-- for direct world drags (the permanent mover) that shouldn't open the full editing +-- GUI. Returns true when it handled the write (a layout is active and not being +-- edited) so the caller skips the base write; false otherwise (no layout / editing +-- — the caller writes base, or the editing-preview path captures it on lock). +-- Mirrors what the toolbar Unlock flow persists, minus the snapshot/diff machinery +-- (a single scalar position key needs none — ApplyRuntimeProfile rebuilds the +-- overlay from profile.overrides on the next layout re-eval). +function AutoProfilesUI:SetActiveLayoutRaidPosition(x, y) + if self:IsEditing() then return false end + local p = self.activeRuntimeProfile + if not p then return false end + p.overrides = p.overrides or {} + p.overrides.raidAnchorX = x + p.overrides.raidAnchorY = y + -- Reflect immediately so GetRaidDB() (overlay view) returns the new value. + if DF.raidOverrides then + DF.raidOverrides.raidAnchorX = x + DF.raidOverrides.raidAnchorY = y + end + if DF.UpdateRaidContainerPosition then DF:UpdateRaidContainerPosition() end + return true +end + +-- ============================================================ +-- RUNTIME WRITE INTERCEPTION +-- When a user changes a global setting via GUI while an auto-profile +-- overlay is active, the proxy __newindex already writes to the real +-- table. This function tells the caller whether the key is overridden +-- so the GUI can suppress visual refresh (the overlay value stays visible). +-- ============================================================ + +-- Intercept a GUI control write for an overridden key. +-- The proxy __newindex already wrote the user's value to the real table. +-- For nested keys we also need to update the real sub-table manually. +-- Returns true if the key is overridden (caller should skip frame refresh). +function AutoProfilesUI:HandleRuntimeWrite(key, value) + if not key then return false end + -- While EDITING a layout, the write must flow to the normal setter + + -- SetProfileSetting so it persists into the layout's override (and previews + -- live). Runtime protection is only for non-editing runtime changes — + -- otherwise it steals the edit and it's lost on reload (e.g. My Group First). + -- Mirrors the WrapDB __newindex guard, which also bows out when IsEditing(). + if self:IsEditing() then return false end + if not self.activeRuntimeProfile or not DF.raidOverrides then return false end + + -- Check if this key (or its parent) is covered by the overlay + local setIndex, setting = ParsePinnedKey(key) + if setIndex and setting then + local pf = DF._realRaidDB.pinnedFrames + if pf and pf.sets and pf.sets[setIndex] then + pf.sets[setIndex][setting] = value + end + -- Post-decouple, `enabled` is the ONLY layout-overridable pinned key + -- (PINNED_OVERRIDABLE). Every other pinned setting reads through the + -- overlay view to the real table, so the value just written IS the live + -- effective value — the caller must apply/refresh it normally. Treating + -- the whole pinnedFrames overlay as "overridden" suppressed callbacks + -- for ALL pinned edits whenever any set had an Enable override. + local isOverridden = false + if setting == "enabled" then + local overlayPF = DF.raidOverrides.pinnedFrames + local overlaySet = overlayPF and overlayPF.sets and overlayPF.sets[setIndex] + -- Only sets carrying an enabled override become view PROXIES (with + -- a metatable); sets without one are the real table by reference. + isOverridden = overlaySet ~= nil and getmetatable(overlaySet) ~= nil + end + DF:Debug("AUTOPROFILE", "HandleRuntimeWrite: pinned key %s — overridden=%s, wrote to real table", key, tostring(isOverridden)) + return isOverridden + end + + local tableName, index = ParseTableKey(key) + if tableName and index then + local tbl = DF._realRaidDB[tableName] + if type(tbl) == "table" then + tbl[index] = value + end + local isOverridden = DF.raidOverrides[tableName] ~= nil + DF:Debug("AUTOPROFILE", "HandleRuntimeWrite: table key %s — overridden=%s, wrote to real table", key, tostring(isOverridden)) + return isOverridden + end + + -- Simple key: write to real table so user changes persist in the global profile + if DF.raidOverrides[key] ~= nil then + DF._realRaidDB[key] = value + DF:Debug("AUTOPROFILE", "HandleRuntimeWrite: %s — overridden, wrote value=%s to global", key, tostring(value)) + return true + end + return false +end + +-- Check if a setting key is currently overridden by an active runtime profile. +-- Used by the GUI to show visual indicators on overridden controls. +function AutoProfilesUI:IsOverriddenByRuntime(key) + if not self.activeRuntimeProfile or not DF.raidOverrides then return false end + + local setIndex, setting = ParsePinnedKey(key) + if setIndex and setting then + -- Compare the specific setting value in the overlay against _realRaidDB. + -- The overlay always has a pinnedFrames copy when any layout is active; + -- only per-setting differences indicate an actual override. + local overlayPF = DF.raidOverrides.pinnedFrames + local realPF = DF._realRaidDB and DF._realRaidDB.pinnedFrames + if not overlayPF or not realPF then return false end + local overlaySet = overlayPF.sets and overlayPF.sets[setIndex] + local realSet = realPF.sets and realPF.sets[setIndex] + if not overlaySet or not realSet then return false end + return not DeepCompare(overlaySet[setting], realSet[setting]) + end + + local tableName, index = ParseTableKey(key) + if tableName and index then + return DF.raidOverrides[tableName] ~= nil + end + + return DF.raidOverrides[key] ~= nil +end + +-- Get the global (baseline) value of an overridden key for display in indicators. +-- With the overlay proxy the real table is always clean, so just read from it. +function AutoProfilesUI:GetRuntimeGlobalValue(key) + local setIndex, setting = ParsePinnedKey(key) + if setIndex and setting then + local pf = DF._realRaidDB and DF._realRaidDB.pinnedFrames + local sets = pf and pf.sets + if sets and sets[setIndex] then + return sets[setIndex][setting] + end + return nil + end + + local tableName, index = ParseTableKey(key) + if tableName and index then + local tbl = DF._realRaidDB[tableName] + if type(tbl) == "table" then + return tbl[index] + end + return nil + end + + return DF._realRaidDB[key] +end + +-- One-shot migration: strip stale "pinnedFrames" direct keys from all profile overrides. +-- These were incorrectly written by the ExitEditing diff scan when a pinned frame was +-- dragged during editing. The key causes the entire pinnedFrames table (including +-- position) to overwrite _realRaidDB on the next EnterEditing, moving the frame. +local dfAutoProfilesMigrated = false +local function MigrateAutoProfileOverrides() + if dfAutoProfilesMigrated then return end + dfAutoProfilesMigrated = true + + local autoDb = DF.db and DF.db.raidAutoProfiles + if not autoDb then return end + + local cleaned = 0 + local contentTypes = { "mythic", "instanced", "openWorld" } + for _, ct in ipairs(contentTypes) do + local profiles = autoDb[ct] and autoDb[ct].profiles + if profiles then + for _, profile in ipairs(profiles) do + if profile.overrides and profile.overrides.pinnedFrames ~= nil then + profile.overrides.pinnedFrames = nil + cleaned = cleaned + 1 + end + end + end + -- Also check single-profile (mythic uses .profile not .profiles) + local single = autoDb[ct] and autoDb[ct].profile + if single and single.overrides and single.overrides.pinnedFrames ~= nil then + single.overrides.pinnedFrames = nil + cleaned = cleaned + 1 + end + end + + if cleaned > 0 then + DF:Debug("AUTOPROFILE", "MigrateAutoProfileOverrides: removed stale pinnedFrames key from %d profile(s)", cleaned) + end +end + +-- Evaluate current content/raid state and apply/remove profiles as needed +function AutoProfilesUI:EvaluateAndApply() + if not DF.initialized then return end + if self:IsEditing() then + DF:Debug("AUTOPROFILE", "EvaluateAndApply: skipped (editing mode)") + return + end + + MigrateAutoProfileOverrides() + + -- Cannot modify secure frames during combat — queue for later + if InCombatLockdown() then + DF:Debug("AUTOPROFILE", "EvaluateAndApply: in combat, queued for PLAYER_REGEN_ENABLED") + self.pendingAutoProfileEval = true + return + end + + -- Determine what profile should be active + local newProfile, contentKey = self:GetActiveProfile() + local oldName = self.activeRuntimeProfile and self.activeRuntimeProfile.name or "none" + local newName = newProfile and newProfile.name or "none" + + -- No change — same profile (or both nil) + if newProfile == self.activeRuntimeProfile then + DF:Debug("AUTOPROFILE", "EvaluateAndApply: no change (active=\"%s\")", oldName) + return + end + if newProfile == nil and self.activeRuntimeProfile == nil then return end + + DF:Debug("AUTOPROFILE", "EvaluateAndApply: switching \"%s\" -> \"%s\"", oldName, newName) + DF:Debug("RAIDPOS", "AutoProfile SWITCH: \"%s\" -> \"%s\" (raid size=%d)", oldName, newName, GetNumGroupMembers()) + + -- Capture whether an old profile was active before clearing state + local oldWasActive = (self.activeRuntimeProfile ~= nil) + + -- Remove old profile if one was active (clears overlay and state only) + if self.activeRuntimeProfile then + self:RemoveRuntimeProfile() + end + + -- Apply new profile if one matches (sets overlay then calls FullProfileRefresh: 1 refresh) + if newProfile then + self:ApplyRuntimeProfile(newProfile, contentKey) + elseif oldWasActive then + -- Profile deactivated (profile→none): refresh once with clean global settings + DF:FullProfileRefresh() + end +end + +-- ============================================================ +-- EVENT FRAME & THROTTLE +-- Listens for content/roster changes and triggers evaluation +-- ============================================================ + +local autoProfileEventFrame = CreateFrame("Frame") +autoProfileEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE") +autoProfileEventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA") +autoProfileEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +autoProfileEventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") + +-- Frame-based throttle: multiple events in the same frame collapse into one evaluation +local autoProfileThrottleFrame = CreateFrame("Frame") +autoProfileThrottleFrame:Hide() +autoProfileThrottleFrame:SetScript("OnUpdate", function(self) + self:Hide() + + -- Suppress any pending roster update — we process it after overlay is settled + local rosterPending = DF._rosterThrottleFrame and DF._rosterThrottleFrame:IsShown() + if rosterPending then + DF._rosterThrottleFrame:Hide() + end + + DF:Debug("RAIDPOS", "AutoProfile throttle FIRE: rosterPending=%s combat=%s", tostring(rosterPending), tostring(InCombatLockdown())) + + AutoProfilesUI:EvaluateAndApply() + + -- Now process roster with correct overlay state + if rosterPending then + DF:Debug("RAIDPOS", "AutoProfile throttle: running deferred ProcessRosterUpdate after EvaluateAndApply") + if not InCombatLockdown() then + DF:ProcessRosterUpdate() + else + local raidDb = DF:GetRaidDB() + if IsInRaid() and not raidDb.raidUseGroups then + DF.pendingFlatLayoutRefresh = true + else + DF.pendingHeaderSettingsApply = true + end + end + end +end) + +local function QueueAutoProfileEval() + autoProfileThrottleFrame:Show() +end + +autoProfileEventFrame:SetScript("OnEvent", function(self, event) + if not DF.initialized then return end + if event == "PLAYER_REGEN_ENABLED" then + if AutoProfilesUI.pendingAutoProfileEval then + DF:Debug("AUTOPROFILE", "Event: %s — processing queued evaluation", event) + AutoProfilesUI.pendingAutoProfileEval = false + QueueAutoProfileEval() + end + return + end + + DF:Debug("AUTOPROFILE", "Event: %s — queuing evaluation", event) + QueueAutoProfileEval() +end) + +-- ============================================================ +-- PRINT OVERRIDES: /df debug overrides +-- Shows which settings are overridden, grouped by tab +-- ============================================================ + +function AutoProfilesUI:PrintOverrides() + -- Determine which profile to report on + local profile, source + if self:IsEditing() then + profile = self.editingProfile + source = L["Editing"] + elseif self.activeRuntimeProfile then + profile = self.activeRuntimeProfile + source = L["Runtime"] + else + local activeProfile = self:GetActiveProfile() + if activeProfile then + profile = activeProfile + source = L["Matched (not applied)"] + end + end + + if not profile then + DF:Say(L["No auto-profile is currently active or being edited."]) + return + end + + if not profile.overrides or not next(profile.overrides) then + DF:Say(format(L["Profile \"%s\" has no overrides."], profile.name or L["Unnamed"])) + return + end + + -- Group by tab (also drives the total, so it matches the deduped display). + local groups, unknownKeys = GroupOverridesByTab(profile.overrides) + + -- Count total: everything shown (mapped groups + "Other"), deduped. + local total = CountDisplayedOverrides(profile.overrides) + + local o = DF:Out(L["Auto-Profile Overrides"]) + o:Field(L["Profile:"], "\"" .. (profile.name or L["Unnamed"]) .. "\" (" .. source .. ")") + o:Field(L["Total:"], format(total > 1 and L["%d overrides"] or L["%d override"], total)) + + -- Sort tab IDs for consistent output + local tabOrder = {} + for tabId in pairs(groups) do + tinsert(tabOrder, tabId) + end + table.sort(tabOrder) + + -- Global (un-overridden) raid values, to diff table overrides against so only + -- the changed leaves print — not the whole stored config. While editing, the + -- live controls write previews into DF._realRaidDB, so the TRUE global lives in + -- globalSnapshot; diffing against _realRaidDB there would show nothing changed. + local realRaid = (self:IsEditing() and self.globalSnapshot) or DF._realRaidDB or (DF.db and DF.db.raid) + local budget = { n = nil } -- chat scrolls: no line cap (depth-capped only) + -- Breadcrumb headers (val == nil) print bare; leaves print "key = value". + local function emitChat(indent, label, val) + if val == nil then + print(indent .. label) + else + print(indent .. label .. " = |cffffffff" .. val .. "|r") + end + end + -- Chat is the DIAGNOSTIC surface -- people paste it for support -- so it shows + -- the readable label AND the raw saved key, unlike the tooltip which shows the + -- label alone. + local function chatKey(key) + local label = KeyLabel(key) + if label == key then return key end + return label .. " |cff808080(" .. key .. ")|r" + end + local function printKey(key, indentBase) + local value = profile.overrides[key] + if type(value) == "table" and not (value.r and value.g and value.b) then + print(indentBase .. chatKey(key) .. ":") + WalkOverrideDiff(value, realRaid and realRaid[key], "", indentBase .. " ", 1, 8, budget, emitChat) + else + local displayValue + if type(value) == "table" then + displayValue = string.format("|cffffffff(%.2f, %.2f, %.2f)|r", value.r, value.g, value.b) + elseif type(value) == "boolean" then + displayValue = value and "|cff00ff00true|r" or "|cffff4444false|r" + else + displayValue = "|cffffffff" .. tostring(value) .. "|r" + end + print(indentBase .. chatKey(key) .. " = " .. displayValue) + end + end + + for _, tabId in ipairs(tabOrder) do + local group = groups[tabId] + print(" |cffffaa00" .. group.tabLabel .. "|r (" .. #group.keys .. "):") + table.sort(group.keys) + for _, key in ipairs(group.keys) do + printKey(key, " ") + end + end + + if #unknownKeys > 0 then + print(" |cff999999" .. L["Other"] .. "|r (" .. #unknownKeys .. "):") + table.sort(unknownKeys) + for _, key in ipairs(unknownKeys) do + printKey(key, " ") + end + end +end + +-- ============================================================ +-- CLEAR OVERRIDE: /df clearoverride +-- Escape hatch to remove a stuck auto-layout override directly, for cases the +-- normal settings UI can't reach (e.g. a pinned-players override when not in a +-- raid). Targets the layout being edited, else the active/matched one — same +-- resolution as /df debug overrides. +-- ============================================================ +function AutoProfilesUI:ClearOverrideCommand(arg) + local profile, editing + if self:IsEditing() then + profile, editing = self.editingProfile, true + elseif self.activeRuntimeProfile then + profile = self.activeRuntimeProfile + else + profile = self:GetActiveProfile() + end + + if not profile or not profile.overrides or not next(profile.overrides) then + DF:Say(L["No auto-profile is currently active or being edited."]) + return + end + + if not arg or arg == "" then + DF:Say(L["Usage: /df clearoverride (see /df debug overrides for keys)"]) + return + end + + -- Build the list of keys to clear: "all", an exact key, or a prefix match + -- (case-insensitive). Prefix lets you clear e.g. all "pinned.1." overrides at once. + local argLower = arg:lower() + local toClear = {} + if argLower == "all" then + for k in pairs(profile.overrides) do toClear[#toClear + 1] = k end + else + for k in pairs(profile.overrides) do + local kl = k:lower() + if kl == argLower or kl:sub(1, #argLower) == argLower then + toClear[#toClear + 1] = k + end + end + end + + if #toClear == 0 then + DF:Say(format(L["No override matching \"%s\" on layout \"%s\"."], arg, profile.name or L["Unnamed"])) + return + end + + table.sort(toClear) + for _, k in ipairs(toClear) do + if editing then + -- Removes the override AND restores the live preview value to global, + -- so ExitEditing's diff won't re-store it. + self:ResetProfileSetting(k) + else + profile.overrides[k] = nil + end + end + + local o = DF:Out("Auto Profiles", format(L["Cleared %d override(s) from layout \"%s\":"], #toClear, profile.name or L["Unnamed"])) + o:More(toClear, 10) + + -- Reflect the change live. + if editing then + self:RefreshTabOverrideStars() + local GUI = DF.GUI + if GUI and GUI.InvalidateAllPages then GUI:InvalidateAllPages() end + if GUI and GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end + elseif self.EvaluateAndApply then + self:EvaluateAndApply() + end +end + +-- ============================================================ +-- DEBUG: Auto Profile Detection Test +-- Usage: /dfautotest - Print current detection results +-- ============================================================ + +DF:RegisterDebugSlash("DFAUTOTEST", "Auto profiles test harness", true, "/dfautotest") +SlashCmdList["DFAUTOTEST"] = function() + local autoDb = DF.db and DF.db.raidAutoProfiles + local enabled = autoDb and autoDb.enabled + local inRaid = IsInRaid() + local contentType = DF.GetContentType and DF:GetContentType() or "N/A" + local raidSize = GetNumGroupMembers() + + local o = DF:Out("Auto Profiles", "detection") + -- "Not in a raid" is the normal state outside one, so it reads NEUTRAL. Only + -- the feature being switched off is worth flagging, and only as a warning. + o:Field("Enabled", enabled and "yes" or "no", enabled and "good" or "warn") + o:Field("In raid", inRaid and "yes" or "no", inRaid and "good" or "neutral") + o:Field("Content type", contentType) + o:Field("Raid size", raidSize) + + local profile, profileKey = AutoProfilesUI:GetActiveProfile() + o:Section("Active profile") + if profile then + local overrideCount = 0 + if profile.overrides then + for _ in pairs(profile.overrides) do + overrideCount = overrideCount + 1 + end + end + o:Field("Name", profile.name or "Unnamed", "good") + o:Field("Matched key", profileKey) + if profile.min and profile.max then + o:Field("Range", profile.min .. "-" .. profile.max .. " players") + end + o:Field("Overrides", overrideCount) + else + o:Line("none - using global settings", "neutral") + end + + o:Section("Runtime state") + local rtProfile = AutoProfilesUI.activeRuntimeProfile + if rtProfile then + local rtOverrides = 0 + if rtProfile.overrides then + for _ in pairs(rtProfile.overrides) do rtOverrides = rtOverrides + 1 end + end + local overlayKeys = 0 + if DF.raidOverrides then + for _ in pairs(DF.raidOverrides) do overlayKeys = overlayKeys + 1 end + end + o:Field("Runtime profile", (rtProfile.name or "Unnamed") + .. " (" .. tostring(AutoProfilesUI.activeRuntimeContentKey) .. ")", "good") + o:Field("Applied overrides", rtOverrides) + o:Field("Overlay keys", overlayKeys) + else + o:Field("Runtime profile", "none", "neutral") + end + -- A deferred evaluation is a WARN: settings the user expects are not applied yet. + o:Field("Pending combat eval", AutoProfilesUI.pendingAutoProfileEval and "yes" or "no", + AutoProfilesUI.pendingAutoProfileEval and "warn" or "neutral") + o:Field("Editing mode", AutoProfilesUI:IsEditing() and "yes" or "no", + AutoProfilesUI:IsEditing() and "warn" or "neutral") + + -- Also list all configured profiles for context + if autoDb then + local configured = {} + for _, ctDef in ipairs(CONTENT_TYPES) do + local key = ctDef.key + if key == "mythic" then + local mp = autoDb.mythic and autoDb.mythic.profile + if mp then + configured[#configured + 1] = { "Mythic", (mp.name or "Unnamed") .. " (20 fixed)" } + end + else + local profiles = autoDb[key] and autoDb[key].profiles or {} + for _, p in ipairs(profiles) do + configured[#configured + 1] = { ctDef.title, p.name .. " (" .. p.min .. "-" .. p.max .. ")" } + end + end + end + o:Section("Configured profiles", #configured) + for _, row in ipairs(configured) do + o:Item(row[1], row[2]) + end + end +end + +-- ============================================================ +-- SETTINGS-PAGE HOOKS +-- ============================================================ +-- These four repaint parts of the settings panel. The engine calls them on +-- every override change and every runtime profile switch -- both of which +-- happen with the panel unloaded, and none of the 11 call sites is guarded. +-- Defining them as no-ops here is what lets those call sites stay untouched; +-- the companion overwrites all four with the real versions when it loads. +-- +-- Written out one by one rather than generated in a loop so that grep and +-- lod_gate_check.py can both see them, same as TestMode/Shim.lua. +function AutoProfilesUI:RefreshEditingUI() end +function AutoProfilesUI:RefreshTabOverrideStars() end +function AutoProfilesUI:ShowSidebarHint() end +function AutoProfilesUI:HideSidebarHint() end + +-- ============================================================ +-- SHARED WITH THE SETTINGS PAGE +-- ============================================================ +-- The page is a separate addon and cannot see this file's locals. Publish the +-- seven it needs. All are read-only from here on -- CONTENT_TYPES is rebuilt +-- in place precisely so this reference stays valid across a locale refresh. +local P = {} +AutoProfilesUI._priv = P +P.CONTENT_TYPES = CONTENT_TYPES +P.CountDisplayedOverrides = CountDisplayedOverrides +P.DeepCopyValue = DeepCopyValue +P.GetOverrideTabId = GetOverrideTabId +P.GroupOverridesByTab = GroupOverridesByTab +P.KeyLabel = KeyLabel +P.WalkOverrideDiff = WalkOverrideDiff diff --git a/DandersFrames/DandersFrames.toc b/DandersFrames/DandersFrames.toc index bed95e5c..7131bc7e 100644 --- a/DandersFrames/DandersFrames.toc +++ b/DandersFrames/DandersFrames.toc @@ -58,6 +58,11 @@ Core\ExportCategories.lua Core.lua Designer\Presets.lua Core\RosterEvents.lua +# Auto-profile switching engine. Resident because it is live behaviour -- it has +# to switch profiles whether or not the settings panel has ever been opened. +# The page half is in the companion. ☠ Must load after Core.lua: it calls +# DF:RegisterLocaleRefresh at file scope. +Core\AutoProfiles.lua # Debug Console (loads early so all modules can use DF:Debug) Debug\DebugConsole.lua diff --git a/DandersFrames_Options/GUI/Pages/AutoProfiles.lua b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua index de4801aa..d9fbaf6f 100644 --- a/DandersFrames_Options/GUI/Pages/AutoProfiles.lua +++ b/DandersFrames_Options/GUI/Pages/AutoProfiles.lua @@ -1,826 +1,27 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. -local DF = DandersFrames - -- ============================================================ --- AUTO PROFILES UI - Raid-only automatic profile switching +-- AUTO PROFILES - the settings page -- ============================================================ - -local AutoProfilesUI = {} -DF.AutoProfilesUI = AutoProfilesUI - +-- Split from the engine, which is resident at DandersFrames/Core/AutoProfiles +-- because profile switching has to work whether or not this page has ever +-- been opened. Everything here is presentation: the page, the two dialogs and +-- the editing banner. +-- +-- These re-declarations are aliases of objects the engine created; they add no +-- state. The engine also defines no-op versions of the four methods below that +-- it calls itself -- RefreshEditingUI, RefreshTabOverrideStars, ShowSidebarHint +-- and HideSidebarHint -- and this file overwrites them with the real ones. +local DF = DandersFrames local L = DF.L local format = string.format - --- Local references - --- ============================================================ --- OVERRIDE KEY LABELS --- The override tooltip and /df debug overrides both list which settings a layout has --- overridden. They used to print the RAW saved key -- "auraDesignerPreset", --- "buffBorderSize" -- so users were shown internal camelCase identifiers. Noise at --- best, and actively misleading where a key's wording no longer matches the UI: --- the designer keys still say "preset" ON PURPOSE (they are exported, so renaming --- them would cost a saved-profile migration to change a name nobody should have --- been seeing) while the UI now calls those Templates. --- --- Derived labels can't be translated -- they come from an identifier, not a string --- table -- but that is no worse than the raw key they replace, and the cases where --- the wording actually matters are pinned to real locale keys below. --- ============================================================ - --- Prettifying these would be WRONG, not merely ugly. -local KEY_LABEL_OVERRIDES = { - auraDesignerPreset = "Aura Designer Template", - textDesignerPreset = "Text Designer Template", -} - --- Initialisms that must not be title-cased into nonsense ("Oor", "Bg", "Dps"). -local KEY_ACRONYMS = { - oor = "OOR", bg = "BG", pvp = "PvP", ui = "UI", hp = "HP", - dps = "DPS", afk = "AFK", cc = "CC", ad = "AD", td = "TD", -} - -local keyLabelCache = {} - -local function KeyLabel(key) - if type(key) ~= "string" then return tostring(key) end - local cached = keyLabelCache[key] - if cached then return cached end - - local out - local override = KEY_LABEL_OVERRIDES[key] - if override then - out = L[override] or override - else - -- camelCase -> spaced, digits split off, then title-case each word. - local spaced = key:gsub("(%l)(%u)", "%1 %2"):gsub("(%a)(%d)", "%1 %2") - local words = {} - for w in spaced:gmatch("%S+") do - words[#words + 1] = KEY_ACRONYMS[w:lower()] or (w:sub(1, 1):upper() .. w:sub(2)) - end - out = table.concat(words, " ") - end - - keyLabelCache[key] = out - return out -end - --- Deep-copy a value (recursive for nested tables) -local function DeepCopyValue(value) - if type(value) ~= "table" then return value end - local copy = {} - for k, v in pairs(value) do - copy[k] = DeepCopyValue(v) - end - return copy -end - --- Content type definitions --- title/description read L["..."]; at file scope that returns the enUS baseline --- (the languageOverride overlay runs later, at ADDON_LOADED). Build them in a --- registered refresh fn so Core rebuilds them after the overlay. -local CONTENT_TYPES = {} - --- ☠ Rebuilds IN PLACE. It used to reassign CONTENT_TYPES, which was harmless --- while everything lived in one file. It is not now: the settings page reads --- this table from _priv, and a locale refresh reassigning the engine's local --- would leave the page holding the previous table forever -- stale labels, no --- error. Keeping the table's identity stable makes any reference safe. -local function RefreshContentTypes() - local rebuilt = { - { - key = "instanced", - title = L["Instanced / PvP"], - description = L["Raids, battlegrounds (1-40)"], - minRange = 1, - maxRange = 40, - isFixed = false, - }, - { - key = "mythic", - title = L["Mythic"], - description = L["20 players (fixed)"], - minRange = 20, - maxRange = 20, - isFixed = true, - }, - { - key = "openWorld", - title = L["Open World"], - description = L["World bosses, outdoor raids (1-40)"], - minRange = 1, - maxRange = 40, - isFixed = false, - }, - } - wipe(CONTENT_TYPES) - for i = 1, #rebuilt do - CONTENT_TYPES[i] = rebuilt[i] - end -end - -RefreshContentTypes() -DF:RegisterLocaleRefresh(RefreshContentTypes) - --- ============================================================ --- LOCAL HELPERS for db.raid access and snapshot lookups --- Defined here so all functions in this file can reference them --- ============================================================ - --- Parse table-based keys (e.g., "raidGroupVisible_1" -> "raidGroupVisible", 1) -local function ParseTableKey(key) - local tableName, index = key:match("^(.+)_(%d+)$") - if tableName and index then - return tableName, tonumber(index) - end - return nil, nil -end - --- Parse pinned frame keys (e.g., "pinned.1.scale" -> 1, "scale") -local function ParsePinnedKey(key) - local setIndex, setting = key:match("^pinned%.(%d+)%.(.+)$") - if setIndex and setting then - return tonumber(setIndex), setting - end - return nil, nil -end - --- Settings that can be overridden per pinned frame set. --- Decoupled from auto layouts (2026-06-07): pinned frames are a global, --- per-mode modular highlight. The ONLY thing a raid auto layout overrides is --- whether each set is shown (enabled) for that layout — everything else is --- global and edited on the Pinned Frames page. This keeps pinned settings out --- of the layout-override store entirely (no more stale pinned.N.* data). -local PINNED_OVERRIDABLE = { - enabled = true, -} - --- Transient / UI-state keys that must NEVER become a layout override — they're --- not per-layout settings, so capturing them poisons the layout. raidLocked is the --- culprit: the per-layout Unlock button unlocks frames DURING an edit session, so --- raidLocked goes false; if editing exits while still unlocked, the diff-scan --- stored raidLocked=false and the layout then forced the frames unlocked whenever --- it activated (the Lock/Unlock button read backwards). `locked` is the party --- equivalent, listed for symmetry. -local NON_OVERRIDABLE_KEYS = { - raidLocked = true, - locked = true, -} - --- True unless `key` is non-overridable: a pinned key (pinned.N.setting) whose --- setting isn't overridable, or a transient UI-state key. The single gate that --- keeps such keys out of the layout override store — used by SetProfileSetting / --- IsSettingOverridden / the ExitEditing diff-scan / ApplyRuntimeProfile. -local function IsKeyOverridable(key) - local _, setting = ParsePinnedKey(key) - if setting then - return PINNED_OVERRIDABLE[setting] == true - end - if NON_OVERRIDABLE_KEYS[key] then return false end - return true -end - --- ============================================================ --- OVERRIDE KEY → TAB MAPPING --- Maps override key prefixes to sidebar tab IDs for display. --- Ordered most-specific first to avoid false matches --- (e.g. "healthText" before "health", "defensiveIcon" before "debuff"). --- ============================================================ --- The 3rd element of each row reads L["..."]; at file scope that returns the --- enUS baseline (the languageOverride overlay runs later, at ADDON_LOADED). --- Build the map in a registered refresh fn so the tab labels follow the locale. -local OVERRIDE_TAB_MAP = {} - -local function RefreshOverrideTabMap() - OVERRIDE_TAB_MAP = { - -- Display - {"soloMode", "display_visibility", L["Visibility"]}, - {"showMinimapButton", "display_visibility", L["Visibility"]}, - {"restedIndicator", "display_visibility", L["Visibility"]}, - {"hidePlayerFrame", "display_visibility", L["Visibility"]}, - {"hideDefaultPlayerFrame", "display_visibility", L["Visibility"]}, - {"hideBlizzardPartyFrames", "display_visibility", L["Visibility"]}, - {"hideBlizzardRaidFrames", "display_visibility", L["Visibility"]}, - {"showBlizzardSideMenu", "display_visibility", L["Visibility"]}, - {"tooltip", "display_tooltips", L["Tooltips"]}, - {"rangeFade", "display_fading", L["Fading"]}, - {"oor", "display_fading", L["Fading"]}, - {"dead", "display_fading", L["Fading"]}, - {"offline", "display_fading", L["Fading"]}, - {"pet", "display_pets", L["Pet Frames"]}, - -- General (specific before generic) - {"fontShadow", "general_fonts", L["Global Fonts"]}, - {"groupLabel", "general_labels", L["Group Labels"]}, - {"raidTestFrameCount", "general_frame", L["Frame"]}, - {"raidUseGroups", "general_frame", L["Frame"]}, - {"raidGroupVisible", "general_frame", L["Frame"]}, - {"raidPlayerPerRow", "general_frame", L["Frame"]}, - {"raidFlat", "general_frame", L["Frame"]}, - {"frame", "general_frame", L["Frame"]}, - {"background", "general_frame", L["Frame"]}, - {"missingHealth", "general_frame", L["Frame"]}, - {"border", "general_frame", L["Frame"]}, - {"anchor", "general_frame", L["Frame"]}, - {"sort", "general_sorting", L["Sorting"]}, - {"selfPosition", "general_sorting", L["Sorting"]}, - {"rolePriority", "general_sorting", L["Sorting"]}, - {"classPriority", "general_sorting", L["Sorting"]}, - {"colorPickerOverride", "general_integrations", L["Integrations"]}, - {"colorPickerGlobalOverride", "general_integrations", L["Integrations"]}, - -- Bars (specific text keys before generic "health" prefix). - -- healthTexture MUST precede healthText: it's the health BAR texture, but - -- a bare prefix match classifies it as health TEXT (and the legacy-text - -- cleanup would then delete a live override). - {"healthTexture", "bars_health", L["Health Bar"]}, - {"healthText", "text_health", L["Health Text"]}, - {"healthFont", "text_health", L["Health Text"]}, - {"health", "bars_health", L["Health Bar"]}, - {"classColor", "bars_health", L["Health Bar"]}, - {"smoothBars", "bars_health", L["Health Bar"]}, - {"resourceBar", "bars_resource", L["Resource Bar"]}, - {"absorbBar", "bars_absorb", L["Absorbs"]}, - {"healAbsorb", "bars_absorb", L["Absorbs"]}, - {"healPrediction", "bars_healpred", L["Heal Prediction"]}, - -- Text - {"statusText", "text_status", L["Status Text"]}, - {"textDesigner", "text_designer", L["Text Designer"]}, - {"name", "text_name", L["Name Text"]}, - -- Auras (specific before generic) - -- myBuffIndicator removed — feature deprecated and hidden from UI - {"missingBuff", "auras_missingbuffs", L["Missing Buffs"]}, - {"defensiveIcon", "auras_defensiveicon", L["Defensive Icon"]}, - {"debuff", "auras_debuffs", L["Debuffs"]}, - {"buff", "auras_buffs", L["Buffs"]}, - {"dispel", "auras_dispel", L["Dispel Overlay"]}, - {"auraDesigner", "auras_auradesigner", L["Aura Designer"]}, - -- Indicators (specific before generic) - {"personalTargeted", "indicators_personal_targeted", L["Personal Targeted"]}, - {"targetedList", "indicators_targetedlist", L["Targeted List"]}, - -- ⚰ DEPRECATED-TARGETED-SPELLS — kept on purpose for now: this row is what - -- labels an existing profile's stale targetedSpell* overrides. Dropping it - -- would leave those overrides unattributed rather than making them go away. - -- It goes when the keys do. See Features\TargetedSpells.lua. - {"targetedSpell", "indicators_targetedspells", L["Targeted Spells"]}, - {"roleIcon", "indicators_icons", L["Icons"]}, - {"leaderIcon", "indicators_icons", L["Icons"]}, - {"raidTargetIcon", "indicators_icons", L["Icons"]}, - {"readyCheckIcon", "indicators_icons", L["Icons"]}, - {"summonIcon", "indicators_icons", L["Icons"]}, - {"resurrectionIcon", "indicators_icons", L["Icons"]}, - {"phasedIcon", "indicators_icons", L["Icons"]}, - {"afkIcon", "indicators_icons", L["Icons"]}, - {"vehicleIcon", "indicators_icons", L["Icons"]}, - {"raidRoleIcon", "indicators_icons", L["Icons"]}, - {"statusIconFont", "indicators_icons", L["Icons"]}, - {"selectionHighlight", "indicators_highlights", L["Highlights"]}, - {"hoverHighlight", "indicators_highlights", L["Highlights"]}, - {"aggroHighlight", "indicators_highlights", L["Highlights"]}, - {"aggro", "indicators_highlights", L["Highlights"]}, - -- Pinned frames (prefix match for "pinned.N.setting" format) - {"pinned.", "general_pinnedframes", L["Pinned Frames"]}, - } -end - -RefreshOverrideTabMap() -DF:RegisterLocaleRefresh(RefreshOverrideTabMap) - --- Returns tabId, tabLabel for a given override key -local function GetOverrideTabId(key) - -- Strip table-based suffix (e.g. "raidGroupVisible_1" → "raidGroupVisible") - local baseKey = key:match("^(.+)_%d+$") or key - for _, entry in ipairs(OVERRIDE_TAB_MAP) do - local prefix = entry[1] - if baseKey:sub(1, #prefix) == prefix then - return entry[2], entry[3] - end - end - return nil, "Unknown" -end - --- ============================================================ --- Cleanup: orphaned legacy built-in text overrides in auto-layouts --- The Text Designer migration took over the built-in Name / Health / Status --- text. A raid auto-layout edited BEFORE that migration can still carry those --- legacy text keys (nameFontSize, healthFontSize, statusText*, …) as overrides — --- now inert (IsLegacyTextHidden hides the legacy FontStrings), so they only --- clutter the override list beside the layout's textDesignerPreset. Strip them. --- --- SAFETY: only runs once the raid TD has ACTUALLY migrated the legacy settings --- (migratedFromLegacy) — the values are preserved in Text Designer first, and we --- never strip while the legacy text is still live. Stripping is visually inert --- (TD ignores these keys); it only declutters. Auto-layouts are raid-only, so we --- gate on the raid text config. Per-profile guard, idempotent. --- ============================================================ --- The DESTRUCTIVE strip decision uses its own boundary-checked matcher, NOT --- the display-oriented GetOverrideTabId prefix map: a bare prefix grab --- classifies healthTexture (the health BAR texture) as healthText — deleting a --- real, live override. A prefix only counts as a legacy text key when the key --- continues with an uppercase letter or digit (camelCase boundary) or ends --- exactly there; two legacy oddballs that don't share the prefixes are listed --- explicitly. -local LEGACY_TEXT_KEY_PREFIXES = { - "nameText", "nameFont", - "healthText", "healthFont", - "statusText", -} -local LEGACY_TEXT_EXACT_KEYS = { - showHealthText = true, - nameColorClass = true, -} -local function IsLegacyTextOverrideKey(key) - if type(key) ~= "string" then return false end - -- Strip table-based suffix ("foo_3" → "foo"), mirroring GetOverrideTabId. - local baseKey = key:match("^(.+)_%d+$") or key - if LEGACY_TEXT_EXACT_KEYS[baseKey] then return true end - for _, prefix in ipairs(LEGACY_TEXT_KEY_PREFIXES) do - local n = #prefix - if baseKey:sub(1, n) == prefix then - local nextChar = baseKey:sub(n + 1, n + 1) - if nextChar == "" or nextChar:match("^[%u%d]") then - return true - end - end - end - return false -end - -local function RaidTextMigrated() - -- The TD legacy migration flags migratedFromLegacy on whatever owns raid text. - -- Preset build: the current raid preset, OR the canonical "Raid" preset the - -- migration always materialises (covers a raid mode repointed elsewhere). - -- Pre-preset build (alpha.6): inline db.raid.textDesigner. - if DF.GetTextDesignerPresets then - if DF.GetModeTextDesigner then - local td = DF:GetModeTextDesigner("raid") - if type(td) == "table" and td.migratedFromLegacy == true then return true end - end - local lib = DF:GetTextDesignerPresets() - local raidPreset = lib and lib["Raid"] - return type(raidPreset) == "table" and raidPreset.migratedFromLegacy == true - end - local rdb = DF.GetRaidDB and DF:GetRaidDB() - local td = rdb and rdb.textDesigner - return type(td) == "table" and td.migratedFromLegacy == true -end - -function DF:CleanupLegacyTextLayoutOverrides() - local prof = DF._realProfile or DF.db - if not prof or prof._legacyTextOverrideCleanupV1 then return end - local autoDb = prof.raidAutoProfiles - if type(autoDb) ~= "table" then return end - -- Don't strip — or set the guard — until the legacy text is safely in TD; an - -- unmigrated profile is retried on its next login / profile switch. - if not RaidTextMigrated() then return end - - local function stripLayout(layout) - local ov = layout and layout.overrides - if type(ov) ~= "table" then return end - local toStrip - for key in pairs(ov) do - if IsLegacyTextOverrideKey(key) then - toStrip = toStrip or {} - toStrip[#toStrip + 1] = key - end - end - if toStrip then for _, key in ipairs(toStrip) do ov[key] = nil end end - end - - for _, ctKey in ipairs({ "instanced", "openWorld" }) do - local ct = autoDb[ctKey] - if type(ct) == "table" and type(ct.profiles) == "table" then - for _, layout in pairs(ct.profiles) do stripLayout(layout) end - end - end - if type(autoDb.mythic) == "table" then stripLayout(autoDb.mythic.profile) end - - prof._legacyTextOverrideCleanupV1 = true -end - --- Groups override keys by tab, returns { [tabId] = { tabLabel, keys = {} } }, unknownKeys -local function GroupOverridesByTab(overrides) - -- A table-valued override (e.g. "raidGroupVisible") and its flat indexed - -- siblings ("raidGroupVisible_5".."_8") can both be stored — the per-control - -- path writes the flat keys, while ExitEditing's diff safety-net writes the - -- whole table. They're the same setting, so when the whole-table form exists - -- we hide the redundant flat siblings (the grouped form reads cleaner). - local wholeTableKeys = {} - for key, val in pairs(overrides) do - if type(val) == "table" then wholeTableKeys[key] = true end - end - local groups = {} - local unknownKeys = {} - for key, _ in pairs(overrides) do - local base = key:match("^(.+)_%d+$") - if base and wholeTableKeys[base] then - -- redundant flat sibling of a whole-table override — skip - else - local tabId, tabLabel = GetOverrideTabId(key) - if tabId then - if not groups[tabId] then - groups[tabId] = { tabLabel = tabLabel, keys = {} } - end - tinsert(groups[tabId].keys, key) - else - tinsert(unknownKeys, key) - end - end - end - return groups, unknownKeys -end - --- Canonical override count for display: everything that actually gets shown --- (mapped groups + "Other"), deduped so redundant flat siblings of a whole-table --- override aren't counted. Used by the row badge, the editing status line, and --- /df debug overrides so all three agree. -local function CountDisplayedOverrides(overrides) - if not overrides then return 0 end - local groups, unknownKeys = GroupOverridesByTab(overrides) - local n = #unknownKeys - for _, group in pairs(groups) do n = n + #group.keys end - return n -end - --- Deep value equality (used to skip leaves of a table override that match global). -local function OverrideDeepEqual(a, b) - if type(a) ~= type(b) then return false end - if type(a) ~= "table" then return a == b end - for k, v in pairs(a) do if not OverrideDeepEqual(v, b[k]) then return false end end - for k in pairs(b) do if a[k] == nil then return false end end - return true -end - --- Walk a table-valued override against its global counterpart and emit ONLY the --- entries that differ. To keep a deep config (Aura Designer) readable we COLLAPSE --- boring single-child wrapper levels into one breadcrumb header --- (auras > HolyPriest > GuardianSpirit) and then list the actual changed leaves --- indented beneath it — instead of repeating that long path on every leaf. --- emit(indent, label, valueStr) -- valueStr == nil → a header/breadcrumb line --- ovr = override table; glob = the global value (DF._realRaidDB[key]) and may be --- nil/non-table (then everything in ovr differs). budget = {n=number} --- (nil n = unlimited); maxDepth collapses deeper tables to "{…}". -local function WalkOverrideDiff(ovr, glob, prefix, indent, depth, maxDepth, budget, emit) - -- Friendly display key: TD elements show their name, not the array index. - local function disp(k) - local ov = ovr[k] - if type(ov) == "table" and ov.contentType and DF.TextDesigner and DF.TextDesigner.ElementDisplayName then - return DF.TextDesigner.ElementDisplayName(ov) or tostring(k) - end - return tostring(k) - end - - -- Collect only the children that differ from global. - local diff = {} - for k in pairs(ovr) do - local gv = (type(glob) == "table") and glob[k] or nil - if not OverrideDeepEqual(ovr[k], gv) then diff[#diff + 1] = k end - end - table.sort(diff, function(a, b) return tostring(a) < tostring(b) end) - - -- Single differing child that is itself a (non-colour) table → fold this level - -- into the breadcrumb and recurse, so wrapper levels don't each cost a line. - if #diff == 1 and depth < maxDepth then - local k = diff[1] - local ov = ovr[k] - if type(ov) == "table" and not (ov.r and ov.g and ov.b) then - local newPrefix = (prefix == "") and disp(k) or (prefix .. " > " .. disp(k)) - local gv = (type(glob) == "table") and glob[k] or nil - return WalkOverrideDiff(ov, gv, newPrefix, indent, depth + 1, maxDepth, budget, emit) - end - end - - -- Print the accumulated breadcrumb once as a header, then list children deeper. - if prefix ~= "" then - emit(indent, prefix, nil) - indent = indent .. " " - end - for _, k in ipairs(diff) do - if budget.n and budget.n <= 0 then emit(indent, "…", nil) return end - local ov = ovr[k] - local gv = (type(glob) == "table") and glob[k] or nil - if type(ov) == "table" and not (ov.r and ov.g and ov.b) then - if depth >= maxDepth then - emit(indent, disp(k), "{…}") - if budget.n then budget.n = budget.n - 1 end - else - -- This child becomes its own (possibly collapsed) sub-section. - WalkOverrideDiff(ov, gv, disp(k), indent, depth + 1, maxDepth, budget, emit) - end - else - local dv - if type(ov) == "table" then dv = string.format("(%.2f, %.2f, %.2f)", ov.r, ov.g, ov.b) - elseif type(ov) == "boolean" then dv = ov and "true" or "false" - else dv = tostring(ov) end - emit(indent, disp(k), dv) - if budget.n then budget.n = budget.n - 1 end - end - end -end - --- Get a value from the real raid database, handling raid keys, table keys, and pinned keys. --- Always reads the real table (DF._realRaidDB) — never the overlay proxy. -local function GetRaidValue(key) - local realRaid = DF._realRaidDB or (DF.db and DF.db.raid) - if not realRaid then return nil end - - -- Pinned frame key (stored at raid.pinnedFrames) - local setIndex, setting = ParsePinnedKey(key) - if setIndex and setting then - local pf = realRaid.pinnedFrames - local sets = pf and pf.sets - if sets and sets[setIndex] then - return sets[setIndex][setting] - end - return nil - end - - -- Table-based raid key - local tableName, index = ParseTableKey(key) - if tableName and index then - local tbl = realRaid[tableName] - if type(tbl) == "table" then - return tbl[index] - end - return nil - end - - -- Direct raid key - return realRaid[key] -end - --- Make `dst` deeply equal `src` while PRESERVING the identity of dst and of every --- nested table whose key/index aligns. Applying/restoring a whole-table override this --- way keeps references captured elsewhere valid — the Text Designer page binds cards --- and offset sliders straight to element objects (CreateSlider(..., elem, "offsetX")), --- and the Preview binds to db.textDesigner. A plain `realRaid.textDesigner = newTable` --- swaps that table (and its .elements array) out from under those bindings, so edits --- land in the orphaned old table while the test frames read the new one — the TD --- "test mode stops updating after a layout switch" freeze. -local function DeepReplaceInPlace(dst, src) - for k, v in pairs(src) do - if type(v) == "table" and type(dst[k]) == "table" then - DeepReplaceInPlace(dst[k], v) - else - dst[k] = v - end - end - for k in pairs(dst) do - if src[k] == nil then dst[k] = nil end - end -end - --- Set a value in the real raid database, handling raid keys, table keys, and pinned keys. --- Always writes to the real table (DF._realRaidDB) — never the overlay proxy. -local function SetRaidValue(key, value) - local realRaid = DF._realRaidDB or (DF.db and DF.db.raid) - if not realRaid then return end - - -- Pinned frame key (stored at raid.pinnedFrames) - local setIndex, setting = ParsePinnedKey(key) - if setIndex and setting then - local pf = realRaid.pinnedFrames - local sets = pf and pf.sets - if sets and sets[setIndex] then - sets[setIndex][setting] = value - end - return - end - - -- Table-based raid key - local tableName, index = ParseTableKey(key) - if tableName and index then - local tbl = realRaid[tableName] - if type(tbl) == "table" then - tbl[index] = value - end - else - -- Whole-table override (textDesigner, auraDesigner, …): update CONTENTS in - -- place (deep) so the table object's identity is preserved and designer - -- bindings to it / its elements survive a layout switch. - local existing = realRaid[key] - if type(existing) == "table" and type(value) == "table" then - DeepReplaceInPlace(existing, value) - else - realRaid[key] = value - end - end -end - --- Get a value from the global snapshot, handling all key types --- Pinned keys are stored directly in the snapshot as "pinned.1.scale" etc -local function GetSnapshotValue(snapshot, key) - if not snapshot then return nil, false end - - -- Direct key match (works for pinned keys and direct raid keys) - if snapshot[key] ~= nil then - return snapshot[key], true - end - - -- Table-based key (e.g., "raidGroupVisible_1" -> snapshot["raidGroupVisible"][1]) - local tableName, index = ParseTableKey(key) - if tableName and index and type(snapshot[tableName]) == "table" then - return snapshot[tableName][index], true - end - - return nil, false -end - --- Find a matching profile within a content type by raid size --- Returns the first profile whose min-max range includes raidSize, or nil -local function FindMatchingProfile(contentKey, raidSize) - local autoDb = DF.db and DF.db.raidAutoProfiles - if not autoDb then return nil end - - local ct = autoDb[contentKey] - if not ct or not ct.profiles then return nil end - - for _, profile in ipairs(ct.profiles) do - if raidSize >= profile.min and raidSize <= profile.max then - DF:Debug("AUTOPROFILE", "FindMatchingProfile: matched \"%s\" for %s (size %d, range %d-%d)", profile.name or "?", contentKey, raidSize, profile.min, profile.max) - return profile - end - end - - DF:Debug("AUTOPROFILE", "FindMatchingProfile: no match for %s (size %d, %d profiles checked)", contentKey, raidSize, #ct.profiles) - return nil -end - --- Initialize database defaults -function AutoProfilesUI:InitDefaults() - if not DF.db.raidAutoProfiles then - DF.db.raidAutoProfiles = { - enabled = false, - howItWorksCollapsed = false, - instanced = { profiles = {} }, - mythic = { profile = nil }, - openWorld = { profiles = {} } - } - end - -- Migration: add howItWorksCollapsed if missing from existing db - if DF.db.raidAutoProfiles.howItWorksCollapsed == nil then - DF.db.raidAutoProfiles.howItWorksCollapsed = false - end - - -- One-time cleanup: strip transient/never-override keys (raidLocked etc.) that - -- an earlier build's diff-scan may have captured into layout overrides. Such a - -- key forced the frames locked/unlocked whenever its layout activated (the - -- Lock/Unlock button read backwards). Runtime/edit paths now skip these keys, - -- but scrub the stored data so override counts are accurate too. - local ap = DF.db.raidAutoProfiles - if not ap.nonOverridableCleanupDone then - local function cleanOverrides(profile) - if profile and profile.overrides then - for key in pairs(NON_OVERRIDABLE_KEYS) do - profile.overrides[key] = nil - end - end - end - if ap.mythic then cleanOverrides(ap.mythic.profile) end - for _, ct in ipairs({ "instanced", "openWorld" }) do - if ap[ct] and ap[ct].profiles then - for _, p in ipairs(ap[ct].profiles) do cleanOverrides(p) end - end - end - ap.nonOverridableCleanupDone = true - end -end - --- Get profiles for a content type -function AutoProfilesUI:GetProfiles(contentKey) - self:InitDefaults() - if contentKey == "mythic" then - local profile = DF.db.raidAutoProfiles.mythic.profile - return profile and {profile} or {} - else - return DF.db.raidAutoProfiles[contentKey].profiles or {} - end -end - --- Check for range overlap within a content type -function AutoProfilesUI:CheckRangeOverlap(contentKey, min, max, excludeIndex) - local profiles = self:GetProfiles(contentKey) - for i, p in ipairs(profiles) do - if i ~= excludeIndex then - if min <= p.max and max >= p.min then - return p -- Returns the overlapping profile - end - end - end - return nil -end - --- Create a profile -function AutoProfilesUI:CreateProfile(contentKey, name, min, max) - self:InitDefaults() - DF:Debug("AUTOPROFILE", "CreateProfile: contentKey=%s name=\"%s\" min=%s max=%s", contentKey, name or "?", tostring(min), tostring(max)) - - if contentKey == "mythic" then - DF.db.raidAutoProfiles.mythic.profile = { - name = name or "Mythic Setup", - overrides = {} - } - DF:Debug("AUTOPROFILE", "CreateProfile: mythic layout created") - return true - end - - -- Check for name conflict - local profiles = DF.db.raidAutoProfiles[contentKey].profiles - for _, p in ipairs(profiles) do - if p.name:lower() == name:lower() then - DF:DebugWarn("AUTOPROFILE", "CreateProfile: name conflict with \"%s\"", name) - return false, L["Name already exists"] - end - end - - -- Check for range overlap - local overlap = self:CheckRangeOverlap(contentKey, min, max) - if overlap then - return false, format(L["Overlaps with \"%s\""], overlap.name) - end - - -- Create the profile - table.insert(profiles, { - name = name, - min = min, - max = max, - overrides = {} - }) - - -- Sort by min range - table.sort(profiles, function(a, b) return a.min < b.min end) - - return true -end - --- Delete a profile -function AutoProfilesUI:DeleteProfile(contentKey, index) - self:InitDefaults() - - -- Capture a reference before deletion to check if it was the active runtime profile - local deletedProfile - if contentKey == "mythic" then - deletedProfile = DF.db.raidAutoProfiles.mythic.profile - DF.db.raidAutoProfiles.mythic.profile = nil - else - local profiles = DF.db.raidAutoProfiles[contentKey].profiles - if profiles[index] then - deletedProfile = profiles[index] - table.remove(profiles, index) - else - DF:DebugWarn("AUTOPROFILE", "DeleteProfile: index %s not found in %s", tostring(index), contentKey) - return false - end - end - - DF:Debug("AUTOPROFILE", "DeleteProfile: removed \"%s\" from %s", deletedProfile and deletedProfile.name or "?", contentKey) - - -- If the deleted profile was the active runtime profile, deactivate it - if deletedProfile and self.activeRuntimeProfile == deletedProfile then - DF:Debug("AUTOPROFILE", "DeleteProfile: deleted layout was active, deactivating overlay") - DF.raidOverrides = nil - self.activeRuntimeProfile = nil - self.activeRuntimeContentKey = nil - if DF.FullProfileRefresh then - DF:FullProfileRefresh() - end - DF:Say(L["Auto-profile deactivated (profile deleted)"]) - end - - return true -end - --- Update profile range -function AutoProfilesUI:UpdateProfileRange(contentKey, index, newMin, newMax) - self:InitDefaults() - DF:Debug("AUTOPROFILE", "UpdateProfileRange: %s index=%s newMin=%d newMax=%d", contentKey, tostring(index), newMin, newMax) - - if contentKey == "mythic" then - return false, L["Mythic has fixed range"] - end - - local profiles = DF.db.raidAutoProfiles[contentKey].profiles - if not profiles[index] then - return false, L["Profile not found"] - end - - -- Check for overlap (excluding current profile) - local overlap = self:CheckRangeOverlap(contentKey, newMin, newMax, index) - if overlap then - DF:DebugWarn("AUTOPROFILE", "UpdateProfileRange: overlap with \"%s\" (%d-%d)", overlap.name, overlap.min, overlap.max) - return false, format(L["Overlaps with \"%s\""], overlap.name) - end - - local oldMin, oldMax = profiles[index].min, profiles[index].max - profiles[index].min = newMin - profiles[index].max = newMax - DF:Debug("AUTOPROFILE", "UpdateProfileRange: \"%s\" range %d-%d -> %d-%d", profiles[index].name or "?", oldMin, oldMax, newMin, newMax) - - -- Re-sort - table.sort(profiles, function(a, b) return a.min < b.min end) - - return true -end +local AutoProfilesUI = DF.AutoProfilesUI +local P = AutoProfilesUI._priv +local CONTENT_TYPES = P.CONTENT_TYPES +local CountDisplayedOverrides = P.CountDisplayedOverrides +local DeepCopyValue = P.DeepCopyValue +local GetOverrideTabId = P.GetOverrideTabId +local GroupOverridesByTab = P.GroupOverridesByTab +local KeyLabel = P.KeyLabel +local WalkOverrideDiff = P.WalkOverrideDiff -- ============================================================ -- UI BUILDING @@ -2398,560 +1599,67 @@ function AutoProfilesUI:SubmitCopyDialog() end -- ============================================================ --- EDIT MODE STATE & FUNCTIONS +-- EDITING BANNER -- ============================================================ --- Runtime state (not persisted) -AutoProfilesUI.editingProfile = nil -AutoProfilesUI.editingContentType = nil -AutoProfilesUI.editingProfileIndex = nil -AutoProfilesUI.globalSnapshot = nil -- Snapshot of true global values during editing +local editingBanner = nil --- Runtime auto-profile state (not persisted — rebuilt on login/reload via events) -AutoProfilesUI.activeRuntimeProfile = nil -- Currently applied profile reference -AutoProfilesUI.activeRuntimeContentKey = nil -- "mythic"/"instanced"/"openWorld" -AutoProfilesUI.pendingAutoProfileEval = false -- Queued evaluation during combat +function AutoProfilesUI:CreateEditingBanner(parent) + if editingBanner then return editingBanner end + + local banner = CreateFrame("Frame", "DandersAutoProfilesEditingBanner", parent, "BackdropTemplate") + banner:SetHeight(50) + DF.GUI:CreateElementBackdrop(banner, { + bgColor = { 0.15, 0.08, 0.03, 1 }, + borderColor = { 1, 0.5, 0.2, 1 }, + }) + banner:SetFrameLevel(parent:GetFrameLevel() + 50) -- Ensure banner is above page content + banner:Hide() + + -- Edit icon + local icon = banner:CreateTexture(nil, "OVERLAY") + icon:SetPoint("LEFT", 12, 0) + icon:SetSize(20, 20) + icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\edit_square") + icon:SetVertexColor(1, 0.5, 0.2) + + -- "Editing:" label + local editLabel = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") + editLabel:SetPoint("LEFT", icon, "RIGHT", 8, 6) + editLabel:SetText(L["Editing:"]) + editLabel:SetTextColor(0.7, 0.7, 0.7) + + -- Profile name and content type + local profileText = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") + profileText:SetPoint("LEFT", editLabel, "RIGHT", 6, 0) + profileText:SetTextColor(1, 0.5, 0.2) + banner.profileText = profileText + + -- Range info line + local infoText = banner:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + infoText:SetPoint("LEFT", icon, "RIGHT", 8, -10) + infoText:SetTextColor(0.5, 0.5, 0.5) + banner.infoText = infoText + + -- Exit Editing button + local exitBtn = CreateFrame("Button", nil, banner, "BackdropTemplate") + exitBtn:SetPoint("RIGHT", -12, 0) + -- Fixed orange accent (raid-only feature identity) so the hover wash doesn't + -- track the party theme like an unscoped StyleButton would. + DF.GUI:StyleButton(exitBtn, { width = 90, height = 26, text = L["Exit Editing"], accent = { r = 1, g = 0.5, b = 0.2 } }) + exitBtn:SetScript("OnClick", function() + AutoProfilesUI:ExitEditing() + end) -function AutoProfilesUI:IsEditing() - return self.editingProfile ~= nil -end + -- (The per-layout "Reset to Global" Aura Designer button was retired when the + -- preset dropdown gained an "Inherit (Global)" entry that does the same job.) --- True when an auto layout is currently driving the live raid frames. Used to --- steer base-position edits (the toolbar Unlock + /df raidunlock) toward the --- active layout's own Unlock button so users don't move the base by accident. -function AutoProfilesUI:IsLayoutActive() - return self.activeRuntimeProfile ~= nil + editingBanner = banner + return banner end -function AutoProfilesUI:GetActiveLayoutName() - return self.activeRuntimeProfile and self.activeRuntimeProfile.name or nil -end - -function AutoProfilesUI:EnterEditing(contentType, profileIndex) - DF:Debug("AUTOPROFILE", "EnterEditing: contentType=%s profileIndex=%s", contentType, tostring(profileIndex)) - - -- Enforcement guard (mirror of the row's blockEdit greying in CreateProfileRow). - -- Editing writes the layout's settings into the live raid db for preview, so - -- the real raid frames only stay correct when the layout being edited is the - -- one actually driving them. Refuse when editing would disturb live frames: - -- a different layout is running, OR you're in a raid group with no layout - -- running. Done here (not just on the button) because the button's disabled - -- state is computed at page-draw time and can go stale if raid state changes. - -- Resolved BEFORE the overlay clear below nils activeRuntimeProfile. - do - local apDb = DF.db.raidAutoProfiles - local requested - if contentType == "mythic" then - requested = apDb.mythic and apDb.mythic.profile - else - local profiles = apDb[contentType] and apDb[contentType].profiles - requested = profiles and profiles[profileIndex] - end - if requested and self.activeRuntimeProfile ~= requested - and (self.activeRuntimeProfile ~= nil or IsInRaid()) then - DF:DebugWarn("AUTOPROFILE", "EnterEditing: refused — only the active layout is editable in a raid group") - -- The row's Edit button greys out with a tooltip, but its disabled - -- state is drawn at page-build time and can be STALE (e.g. you - -- joined a raid with the window open). A silent refusal on a - -- clickable-looking button reads as "broken" — say why in chat and - -- redraw the page so the button greys correctly. - local msg - if self.activeRuntimeProfile then - msg = (L["Only the active layout can be edited\nwhile auto layouts are running."] or ""):gsub("\n", " ") - else - msg = L["While in a raid group you can only edit the active layout. Leave the raid group to edit other layouts."] - end - DF:Say((msg or "")) - local GUI = DF.GUI - if GUI and GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - return false, "Blocked: live raid frames" - end - end - - -- Clear overlay so snapshot captures true globals (no RemoveRuntimeProfile - -- needed — real table was never mutated) - if self.activeRuntimeProfile then - DF:Debug("AUTOPROFILE", "EnterEditing: clearing active runtime overlay before snapshot") - DF.raidOverrides = nil - self.activeRuntimeProfile = nil - self.activeRuntimeContentKey = nil - end - - local autoDb = DF.db.raidAutoProfiles - - if contentType == "mythic" then - self.editingProfile = autoDb.mythic.profile - self.editingProfileIndex = nil - else - local profiles = autoDb[contentType] and autoDb[contentType].profiles - if profiles and profiles[profileIndex] then - self.editingProfile = profiles[profileIndex] - self.editingProfileIndex = profileIndex - else - DF:DebugWarn("AUTOPROFILE", "EnterEditing: profile not found at index %s", tostring(profileIndex)) - return false, "Profile not found" - end - end - - self.editingContentType = contentType - DF:Debug("AUTOPROFILE", "EnterEditing: editing \"%s\" (%s)", self.editingProfile.name or "?", contentType) - - -- Snapshot ALL true global values before anything gets modified - -- This lets controls freely write to DF._realRaidDB for live preview while - -- SetProfileSetting/GetGlobalValue always know the original globals - self.globalSnapshot = {} - for key, value in pairs(DF._realRaidDB) do - self.globalSnapshot[key] = DeepCopyValue(value) - end - - -- Snapshot pinned frames overridable settings (stored as "pinned.N.setting" keys) - -- Pinned frames live at raid.pinnedFrames - -- IMPORTANT: Iterate PINNED_OVERRIDABLE keys rather than set keys to ensure - -- we capture ALL overridable settings, even ones that are nil due to missing migration. - -- If a key is nil, backfill a default so both the snapshot and set are consistent. - -- Only `enabled` is overridable (PINNED_OVERRIDABLE = {enabled}), and the loop - -- below reads PINNED_DEFAULTS only for the keys it iterates — so this table needs - -- just `enabled`. (It previously listed ~13 dead keys, incl. a keepOfflinePlayers - -- default that contradicted the real Config/Options default.) - local PINNED_DEFAULTS = { - enabled = false, - } - local pinnedFrames = DF._realRaidDB and DF._realRaidDB.pinnedFrames - if pinnedFrames and pinnedFrames.sets then - for setIdx, set in pairs(pinnedFrames.sets) do - for setting, _ in pairs(PINNED_OVERRIDABLE) do - local value = set[setting] - -- Backfill nil values with defaults so snapshot always has a baseline - if value == nil then - value = PINNED_DEFAULTS[setting] - set[setting] = DeepCopyValue(value) - value = set[setting] - end - local snapKey = "pinned." .. setIdx .. "." .. setting - self.globalSnapshot[snapKey] = DeepCopyValue(value) - end - end - end - - -- Persist a lightweight recovery flag so a crash/disconnect during editing - -- can be detected on next login and contaminated values reset to defaults - local recoveryKeys = {} - for key in pairs(self.globalSnapshot) do - recoveryKeys[#recoveryKeys + 1] = key - end - DF.db.raidAutoEditingRecovery = { - contentType = contentType, - profileIndex = profileIndex, - snapshotKeys = recoveryKeys, - } - - -- Apply existing overrides to db.raid for live preview. - -- Skip "pinnedFrames" as a direct key — it is a stale artifact from an older - -- bug where dragging a pinned frame during editing caused the entire pinnedFrames - -- table (including position) to be saved as a profile override. Pinned frame - -- settings are managed via "pinned.N.setting" keys; applying the raw table would - -- overwrite _realRaidDB.pinnedFrames and move the frame to the stored position. - local overrideCount = 0 - if self.editingProfile.overrides then - for key, value in pairs(self.editingProfile.overrides) do - if key == "pinnedFrames" then - DF:DebugWarn("AUTOPROFILE", "EnterEditing: skipping stale pinnedFrames direct override key") - elseif not IsKeyOverridable(key) then - -- transient/never-override key (e.g. a stale raidLocked) — don't - -- apply it to the live preview (ExitEditing self-heals it away). - DF:DebugWarn("AUTOPROFILE", "EnterEditing: skipping non-overridable override \"%s\"", key) - else - SetRaidValue(key, DeepCopyValue(value)) - overrideCount = overrideCount + 1 - end - end - end - DF:Debug("AUTOPROFILE", "EnterEditing: applied %d overrides for live preview", overrideCount) - - -- Refresh pinned frames to show overridden settings in live preview. - -- * In an actual raid: re-apply each set's enabled + layout to the LIVE - -- pinned frames (their methods key off live IsInRaid()). - -- * In raid TEST mode: the live frames stay party-mode and must not be - -- touched (that would apply the raid layout to the party set / leak the - -- party header). Instead just re-run the raid test-mode pinned frames so - -- they re-size to the layout's overridden frame width via their own - -- mode-explicit path (ApplyPlayerTestLayout reads the raid DB). - if DF.PinnedFrames and IsInRaid() then - local pf = DF.db.raid and DF.db.raid.pinnedFrames - for i = 1, DF.PinnedFrames.MAX_SETS do - local setEnabled = pf and pf.sets and pf.sets[i] and pf.sets[i].enabled - DF.PinnedFrames:SetEnabled(i, setEnabled or false) - DF.PinnedFrames:ApplyLayoutSettings(i) - DF.PinnedFrames:ResizeContainer(i) - DF.PinnedFrames:UpdateHeaderNameList(i) - end - elseif DF.PinnedFrames and DF.raidTestMode then - DF.PinnedFrames:ExitTestMode() - DF.PinnedFrames:EnterTestMode() - end - - -- Refresh test mode frames so they display override values. - -- PositionTestRaidContainer MUST run before RefreshTestFramesWithLayout: - -- the overrides written above may include a raidAnchorX/Y for this profile, - -- and frames are positioned relative to testRaidContainer's BOTTOMLEFT. - -- If the container isn't moved first, frames land at the wrong screen position. - -- (#906) - if DF.raidTestMode then - if DF.PositionTestRaidContainer then - DF:PositionTestRaidContainer() - end - -- If the mover is visible (frames unlocked), sync its position and scale - -- to match the freshly-positioned test container. The profile overrides - -- applied above may include a different raidAnchorX/Y or frameScale, so - -- the mover needs to follow the container or it will appear offset. - if DF.raidMoverFrame and DF.raidMoverFrame:IsShown() then - local moverDb = DF:GetRaidDB() - local moverScale = moverDb.frameScale or 1.0 - DF.raidMoverFrame:SetScale(moverScale) - DF.raidMoverFrame:ClearAllPoints() - DF.raidMoverFrame:SetPoint("CENTER", UIParent, "CENTER", (moverDb.raidAnchorX or 0) / moverScale, (moverDb.raidAnchorY or 0) / moverScale) - end - -- Refresh position panel inputs to reflect the newly-applied override anchor/scale - if DF.positionPanel and DF.positionPanel:IsShown() and DF.UpdatePositionPanel then - DF:UpdatePositionPanel() - end - if DF.RefreshTestFramesWithLayout then - DF:RefreshTestFramesWithLayout() - end - end - - -- Refresh the GUI to show editing banner and disable Auto Profiles tab - self:RefreshEditingUI() - - -- Switch to a settings tab (e.g., Layout/Frame) - -- Suppress sidebar hint dismissal for this initial SelectTab call - self.suppressHintDismiss = true - local GUI = DF.GUI - -- The page build cache is keyed on mode only, not the active layout, so other - -- tabs (Aura Designer / Text Designer frame previews especially) would re-show - -- the previous layout's geometry. Invalidate every page so each rebuilds for - -- this layout's frame size on next view. - if GUI and GUI.InvalidateAllPages then GUI:InvalidateAllPages() end - if GUI and GUI.SelectTab then - GUI.SelectTab("general_frame") - end - self.suppressHintDismiss = false - - -- Show sidebar onboarding hint (dismissed on first user tab click) - self:ShowSidebarHint() - - -- Show override stars on tabs that have overridden settings - self:RefreshTabOverrideStars() - - return true -end - --- Deep-compare two values (recursive for nested tables) -local function DeepCompare(a, b) - if type(a) ~= type(b) then return false end - if type(a) ~= "table" then return a == b end - for k, v in pairs(a) do - if not DeepCompare(v, b[k]) then return false end - end - for k in pairs(b) do - if a[k] == nil then return false end - end - return true -end - -function AutoProfilesUI:ExitEditing(skipUIUpdates) - DF:Debug("AUTOPROFILE", "ExitEditing: profile=\"%s\" skipUIUpdates=%s", self.editingProfile and self.editingProfile.name or "?", tostring(skipUIUpdates)) - - -- Diff safety net: before restoring globals, scan live values vs snapshot - -- to catch any overrides that weren't explicitly tracked by controls - if self.editingProfile and self.globalSnapshot then - if not self.editingProfile.overrides then - self.editingProfile.overrides = {} - end - local overrides = self.editingProfile.overrides - local autoStored, autoCleaned = 0, 0 - - for key, snapshotVal in pairs(self.globalSnapshot) do - -- Skip "pinnedFrames" as a direct key. Pinned frame settings are tracked - -- via "pinned.N.setting" keys (PINNED_OVERRIDABLE). Comparing the whole - -- pinnedFrames table would cause position changes (from dragging during - -- editing) to be stored as a top-level profile override, which then - -- overwrites _realRaidDB.pinnedFrames and moves the frame on next activation. - if key == "pinnedFrames" then - -- Clean up any stale pinnedFrames override that may already exist - if overrides[key] ~= nil then - overrides[key] = nil - autoCleaned = autoCleaned + 1 - DF:DebugWarn("AUTOPROFILE", "ExitEditing: removed stale pinnedFrames direct override key") - end - elseif not IsKeyOverridable(key) then - -- Transient/never-override key (e.g. raidLocked) — never store it, - -- and self-heal any override an earlier build captured. - if overrides[key] ~= nil then - overrides[key] = nil - autoCleaned = autoCleaned + 1 - DF:DebugWarn("AUTOPROFILE", "ExitEditing: removed non-overridable override \"%s\"", key) - end - else - local currentVal = GetRaidValue(key) - local matches = DeepCompare(snapshotVal, currentVal) - - -- If this table-valued setting is already tracked via flat indexed - -- sub-overrides ("key_5".."key_8", e.g. raidGroupVisible written by the - -- group-visibility checkboxes), don't ALSO store the whole table here — - -- that double-stores the same setting and collides in - -- ApplyRuntimeProfile. The flat sub-keys are canonical. - local hasFlatSiblings = false - if type(currentVal) == "table" then - for ok in pairs(overrides) do - if ok:match("^(.+)_%d+$") == key then hasFlatSiblings = true break end - end - end - - if hasFlatSiblings then - -- Drop any stale whole-table override (older saves stored it) so - -- existing dirty profiles self-heal on next exit-editing. - if overrides[key] ~= nil then - overrides[key] = nil - autoCleaned = autoCleaned + 1 - DF:DebugWarn("AUTOPROFILE", "ExitEditing: removed redundant whole-table override \"%s\" (tracked via flat keys)", key) - end - elseif not matches then - -- Only deep-copy when override is new or has actually changed - if overrides[key] == nil or not DeepCompare(overrides[key], currentVal) then - overrides[key] = DeepCopyValue(currentVal) - autoStored = autoStored + 1 - end - elseif matches and overrides[key] ~= nil - and key ~= "textDesignerPreset" and key ~= "auraDesignerPreset" then - -- Designer preset refs are exempt from the equal→remove - -- cleanup: SetModeDesignerPreset deliberately stamps the - -- override even when the chosen name EQUALS the global's - -- (explicit pin vs Inherit is presence-based). Removing it - -- here silently reverted the layout to "Inherit (Global)" - -- on exit. - overrides[key] = nil - autoCleaned = autoCleaned + 1 - end - end - end - - -- Remove orphan keys created during editing that aren't tracked overrides - local orphansRemoved = 0 - for key in pairs(DF._realRaidDB) do - if self.globalSnapshot[key] == nil and not overrides[key] then - DF._realRaidDB[key] = nil - orphansRemoved = orphansRemoved + 1 - end - end - - local totalOverrides = 0 - for _ in pairs(overrides) do totalOverrides = totalOverrides + 1 end - DF:Debug("AUTOPROFILE", "ExitEditing: diff scan — autoStored=%d autoCleaned=%d orphansRemoved=%d totalOverrides=%d", autoStored, autoCleaned, orphansRemoved, totalOverrides) - end - - -- Restore all modified values back to their true globals - -- SetRaidValue handles raid keys, table keys, and pinned keys - if self.globalSnapshot then - for key, originalValue in pairs(self.globalSnapshot) do - SetRaidValue(key, DeepCopyValue(originalValue)) - end - end - - self.editingProfile = nil - self.editingContentType = nil - self.editingProfileIndex = nil - self.globalSnapshot = nil - - -- Clear crash recovery flag — editing exited cleanly - if DF.db then - DF.db.raidAutoEditingRecovery = nil - end - - -- Hide sidebar hint if still showing - self:HideSidebarHint() - - -- Clear override stars (they'll refresh after EvaluateAndApply if a runtime profile re-applies) - self:RefreshTabOverrideStars() - - -- Skip UI updates when GUI is closing (UI will reset on next open anyway) - if skipUIUpdates then return end - - -- Re-evaluate auto-profiles BEFORE UpdateAll so any re-applied overlay is in - -- place when frames read settings — otherwise UpdateAll would read globals, - -- then the delayed evaluator would re-apply the overlay causing a flicker. - -- EvaluateAndApply internally checks InCombatLockdown so this is combat-safe. - AutoProfilesUI:EvaluateAndApply() - - -- Refresh frames to show global settings again - if DF.UpdateAll then DF:UpdateAll() end - - -- Test mode needs a full layout refresh so frames re-read restored global values - -- UpdateAll() only calls UpdateRaidTestFrames() which skips ApplyTestFrameLayout(), - -- so frame sizes, textures, fonts etc. would remain stuck on overridden values. - -- PositionTestRaidContainer MUST run before RefreshTestFramesWithLayout so the - -- container is at the restored base anchor before frames are positioned against it. - -- (#906) - if DF.raidTestMode then - if DF.PositionTestRaidContainer then - DF:PositionTestRaidContainer() - end - -- Sync mover position/scale to match the restored base anchor. - -- EvaluateAndApply above may have re-applied a runtime overlay, so - -- DF:GetRaidDB() here reflects whichever values are now authoritative. - if DF.raidMoverFrame and DF.raidMoverFrame:IsShown() then - local moverDb = DF:GetRaidDB() - local moverScale = moverDb.frameScale or 1.0 - DF.raidMoverFrame:SetScale(moverScale) - DF.raidMoverFrame:ClearAllPoints() - DF.raidMoverFrame:SetPoint("CENTER", UIParent, "CENTER", (moverDb.raidAnchorX or 0) / moverScale, (moverDb.raidAnchorY or 0) / moverScale) - end - -- Refresh position panel inputs to reflect the restored anchor/scale values. - -- EvaluateAndApply above may have re-applied a runtime overlay, so - -- DF:GetRaidDB() is already authoritative at this point. - if DF.positionPanel and DF.positionPanel:IsShown() and DF.UpdatePositionPanel then - DF:UpdatePositionPanel() - end - if DF.RefreshTestFramesWithLayout then - DF:RefreshTestFramesWithLayout() - end - elseif DF.testMode and DF.RefreshTestFramesWithLayout then - DF:RefreshTestFramesWithLayout() - end - - -- Refresh pinned frames to show global settings again (mirror of EnterEditing): - -- live raid re-applies to the real frames; raid test mode just re-runs the test - -- frames so they re-size back to the global frame width. - if DF.PinnedFrames and IsInRaid() then - local pf = DF.db.raid and DF.db.raid.pinnedFrames - for i = 1, DF.PinnedFrames.MAX_SETS do - -- Restore enabled state first (hides containers if globally disabled) - local setEnabled = pf and pf.sets and pf.sets[i] and pf.sets[i].enabled - DF.PinnedFrames:SetEnabled(i, setEnabled or false) - DF.PinnedFrames:ApplyLayoutSettings(i) - DF.PinnedFrames:ResizeContainer(i) - DF.PinnedFrames:UpdateHeaderNameList(i) - end - elseif DF.PinnedFrames and DF.raidTestMode then - DF.PinnedFrames:ExitTestMode() - DF.PinnedFrames:EnterTestMode() - end - - -- Refresh UI to hide banner and re-enable Auto Profiles tab - self:RefreshEditingUI() - - -- Switch back to Auto Profiles tab - local GUI = DF.GUI - -- Active layout reverted to global — invalidate page caches so tabs (and their - -- frame previews) rebuild at the global frame size on next view. - if GUI and GUI.InvalidateAllPages then GUI:InvalidateAllPages() end - if GUI and GUI.SelectTab then - GUI.SelectTab("profiles_auto") - end -end - -function AutoProfilesUI:GetEditingInfo() - if not self:IsEditing() then return nil end - - local profile = self.editingProfile - local contentType = self.editingContentType - - -- Get content type display name - local contentName = "Unknown" - for _, ct in ipairs(CONTENT_TYPES) do - if ct.key == contentType then - contentName = ct.title - break - end - end - - -- Get range display - local rangeText - if contentType == "mythic" then - rangeText = L["20 players (fixed)"] - else - rangeText = format(L["%d-%d players"], profile.min, profile.max) - end - - -- Count overrides - local overrideCount = 0 - if profile.overrides then - for _ in pairs(profile.overrides) do - overrideCount = overrideCount + 1 - end - end - - return { - name = profile.name or L["Unnamed"], - contentType = contentType, - contentName = contentName, - rangeText = rangeText, - overrideCount = overrideCount, - } -end - --- ============================================================ --- EDITING BANNER --- ============================================================ - -local editingBanner = nil - -function AutoProfilesUI:CreateEditingBanner(parent) - if editingBanner then return editingBanner end - - local banner = CreateFrame("Frame", "DandersAutoProfilesEditingBanner", parent, "BackdropTemplate") - banner:SetHeight(50) - DF.GUI:CreateElementBackdrop(banner, { - bgColor = { 0.15, 0.08, 0.03, 1 }, - borderColor = { 1, 0.5, 0.2, 1 }, - }) - banner:SetFrameLevel(parent:GetFrameLevel() + 50) -- Ensure banner is above page content - banner:Hide() - - -- Edit icon - local icon = banner:CreateTexture(nil, "OVERLAY") - icon:SetPoint("LEFT", 12, 0) - icon:SetSize(20, 20) - icon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\edit_square") - icon:SetVertexColor(1, 0.5, 0.2) - - -- "Editing:" label - local editLabel = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") - editLabel:SetPoint("LEFT", icon, "RIGHT", 8, 6) - editLabel:SetText(L["Editing:"]) - editLabel:SetTextColor(0.7, 0.7, 0.7) - - -- Profile name and content type - local profileText = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") - profileText:SetPoint("LEFT", editLabel, "RIGHT", 6, 0) - profileText:SetTextColor(1, 0.5, 0.2) - banner.profileText = profileText - - -- Range info line - local infoText = banner:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - infoText:SetPoint("LEFT", icon, "RIGHT", 8, -10) - infoText:SetTextColor(0.5, 0.5, 0.5) - banner.infoText = infoText - - -- Exit Editing button - local exitBtn = CreateFrame("Button", nil, banner, "BackdropTemplate") - exitBtn:SetPoint("RIGHT", -12, 0) - -- Fixed orange accent (raid-only feature identity) so the hover wash doesn't - -- track the party theme like an unscoped StyleButton would. - DF.GUI:StyleButton(exitBtn, { width = 90, height = 26, text = L["Exit Editing"], accent = { r = 1, g = 0.5, b = 0.2 } }) - exitBtn:SetScript("OnClick", function() - AutoProfilesUI:ExitEditing() - end) - - -- (The per-layout "Reset to Global" Aura Designer button was retired when the - -- preset dropdown gained an "Inherit (Global)" entry that does the same job.) - - editingBanner = banner - return banner -end - -function AutoProfilesUI:UpdateEditingBanner() - if not editingBanner then return end +function AutoProfilesUI:UpdateEditingBanner() + if not editingBanner then return end local info = self:GetEditingInfo() if info then @@ -3271,889 +1979,3 @@ function AutoProfilesUI:RefreshTabOverrideStars() end end --- ============================================================ --- OVERRIDE SYSTEM FUNCTIONS --- ============================================================ - -function AutoProfilesUI:SetProfileSetting(key, value) - if not self.editingProfile then return false end - - -- Non-overridable pinned setting (everything except `enabled` post-decouple): - -- never store it as a layout override. Strip any existing one and bail so the - -- control's plain global write is the only effect. - if not IsKeyOverridable(key) then - if self.editingProfile.overrides then - self.editingProfile.overrides[key] = nil - end - return false - end - - -- Ensure overrides table exists - if not self.editingProfile.overrides then - self.editingProfile.overrides = {} - end - - -- Get the true global from snapshot (db.raid has been modified by the control already) - local globalValue, found = GetSnapshotValue(self.globalSnapshot, key) - if not found then - globalValue = GetRaidValue(key) - end - - -- Compare values (recursive deep compare handles nested tables like colors) - local valuesMatch = DeepCompare(value, globalValue) - - if valuesMatch then - -- Same as global, remove override - self.editingProfile.overrides[key] = nil - DF:Debug("AUTOPROFILE", "SetProfileSetting: %s matches global, override removed", key) - else - -- Different from global, store override (deep copy to avoid reference issues) - self.editingProfile.overrides[key] = DeepCopyValue(value) - DF:Debug("AUTOPROFILE", "SetProfileSetting: %s overridden (value=%s)", key, tostring(value)) - end - - -- Refresh tab stars so they update live as settings change - self:RefreshTabOverrideStars() - - return true -end - --- Reset a setting to global (remove override) -function AutoProfilesUI:ResetProfileSetting(key) - if not self.editingProfile then return false end - DF:Debug("AUTOPROFILE", "ResetProfileSetting: %s — restoring to global", key) - - if self.editingProfile.overrides then - self.editingProfile.overrides[key] = nil - end - - -- Restore the actual db value to the true global (from snapshot) - local globalValue, found = GetSnapshotValue(self.globalSnapshot, key) - if not found then - globalValue = GetRaidValue(key) - end - - -- Deep copy to avoid mutating the snapshot - SetRaidValue(key, DeepCopyValue(globalValue)) - - -- Refresh tab stars so they update live as overrides are removed - self:RefreshTabOverrideStars() - - return true -end - --- Get the global value for a setting (for display purposes) --- Returns a deep copy to prevent callers from mutating the snapshot -function AutoProfilesUI:GetGlobalValue(key) - -- When editing, return the true global from snapshot (db.raid may have overridden values) - local snapshotVal, found = GetSnapshotValue(self.globalSnapshot, key) - if found then - return DeepCopyValue(snapshotVal) - end - return GetRaidValue(key) -end - --- Check if a setting is currently overridden -function AutoProfilesUI:IsSettingOverridden(key) - if not IsKeyOverridable(key) then return false end - if not self.editingProfile or not self.editingProfile.overrides then - return false - end - return self.editingProfile.overrides[key] ~= nil -end - --- Is a pinned-frame SETTING (bare name, e.g. "enabled" / "scale") overridable --- per auto layout? Post-decouple only `enabled` is. The pinned options page uses --- this to decide whether to show any override UI (star / reset / Global: text) --- for a control — non-overridable controls show none, making it clear they're --- global/independent of auto layouts. -function AutoProfilesUI:IsPinnedSettingOverridable(setting) - return PINNED_OVERRIDABLE[setting] == true -end - --- Get active profile based on current content/raid size (for runtime, not editing) --- Returns: profile, contentKey (or nil, nil if no match) -function AutoProfilesUI:GetActiveProfile() - local autoDb = DF.db.raidAutoProfiles - if not autoDb or not autoDb.enabled then - DF:Debug("AUTOPROFILE", "GetActiveProfile: disabled or no autoDb") - return nil - end - - -- Must be in a raid group for auto-profiles to apply - if not IsInRaid() then - DF:Debug("AUTOPROFILE", "GetActiveProfile: not in raid") - return nil - end - - -- Use the existing content type detection from Core.lua - local contentType = DF:GetContentType() - if not contentType then - DF:Debug("AUTOPROFILE", "GetActiveProfile: no content type detected") - return nil - end - - local raidSize = GetNumGroupMembers() - DF:Debug("AUTOPROFILE", "GetActiveProfile: contentType=%s raidSize=%d", contentType, raidSize) - - -- Mythic: single profile, no range check needed - if contentType == "mythic" then - local mythicProfile = autoDb.mythic and autoDb.mythic.profile - if mythicProfile then - DF:Debug("AUTOPROFILE", "GetActiveProfile: matched mythic layout \"%s\"", mythicProfile.name or "?") - return mythicProfile, "mythic" - end - DF:Debug("AUTOPROFILE", "GetActiveProfile: mythic content but no layout configured") - return nil - end - - -- Map content type to auto-profile key - -- "battleground" falls under "instanced" (the "Instanced / PvP" category) - local profileKey - if contentType == "instanced" or contentType == "battleground" then - profileKey = "instanced" - elseif contentType == "openWorld" then - profileKey = "openWorld" - else - DF:Debug("AUTOPROFILE", "GetActiveProfile: unmapped content type \"%s\", no layout", contentType) - return nil - end - - -- Find matching profile by raid size within the content type - local profile = FindMatchingProfile(profileKey, raidSize) - if profile then - return profile, profileKey - end - - DF:Debug("AUTOPROFILE", "GetActiveProfile: no matching range for %s (size %d)", profileKey, raidSize) - return nil -end - --- ============================================================ --- RUNTIME PROFILE APPLICATION --- Applies/removes auto-profile overrides at runtime based on --- content type and raid size changes --- ============================================================ - --- Get a display name for a content key -local function GetContentDisplayName(contentKey) - if contentKey == "mythic" then return L["Mythic"] - elseif contentKey == "instanced" then return L["Instanced/PvP"] - elseif contentKey == "openWorld" then return L["Open World"] - end - return contentKey or L["Unknown"] -end - --- Apply a profile's overrides as a read-through overlay (does NOT mutate the real raid table) -function AutoProfilesUI:ApplyRuntimeProfile(profile, contentKey) - if not profile or not profile.overrides then return end - DF:Debug("AUTOPROFILE", "ApplyRuntimeProfile: \"%s\" (%s)", profile.name or "?", contentKey) - - -- Group nested overrides by parent table so multiple overrides on the same - -- parent (e.g. raidGroupVisible_1 and raidGroupVisible_3) share one copy - local tableGroups = {} -- { [parentTable] = { [index] = value, ... } } - local pinnedGroups = {} -- { [setIndex] = { [setting] = value, ... } } - local simpleKeys = {} -- { [key] = value } - - for key, value in pairs(profile.overrides) do - -- Defensive: never apply a transient/never-override key (e.g. a raidLocked - -- captured by an earlier build) to the live overlay, or the layout would - -- force the frames locked/unlocked whenever it activates. - if not IsKeyOverridable(key) then - -- skip - else - local setIndex, setting = ParsePinnedKey(key) - if setIndex and setting then - if not pinnedGroups[setIndex] then pinnedGroups[setIndex] = {} end - pinnedGroups[setIndex][setting] = value - else - local tableName, index = ParseTableKey(key) - if tableName and index then - if not tableGroups[tableName] then tableGroups[tableName] = {} end - tableGroups[tableName][index] = value - else - simpleKeys[key] = value - end - end - end - end - - -- Build the overlay table - local overlay = {} - - -- Simple keys: direct copy - for key, value in pairs(simpleKeys) do - overlay[key] = DeepCopyValue(value) - end - - -- Table keys: deep-copy parent from real table, apply overrides - for tableName, indices in pairs(tableGroups) do - local parent = DeepCopyValue(DF._realRaidDB[tableName]) - if type(parent) ~= "table" then parent = {} end - for index, value in pairs(indices) do - parent[index] = DeepCopyValue(value) - end - overlay[tableName] = parent - end - - -- Pinned keys: build a LIVE VIEW of the real pinnedFrames, not a deep copy. - -- Post-decouple the only pinned override is per-set `enabled`, so every other - -- pinned setting must read through to the live global (edits reflect - -- immediately, and there's nothing to go stale). Each set that has an - -- `enabled` override becomes a thin proxy that returns the overridden enabled - -- but reads every other field live from the real set; sets with no override - -- are the real table by reference. The whole pinnedFrames table is itself a - -- live view (top-level fields read through to real). - if next(pinnedGroups) then - local realPF = DF._realRaidDB.pinnedFrames or {} - local realSets = type(realPF.sets) == "table" and realPF.sets or {} - local viewSets = {} - for setIdx, realSet in pairs(realSets) do - local ov = pinnedGroups[setIdx] - if ov and ov.enabled ~= nil then - -- __newindex: WRITES must reach the real set, or every direct - -- field write from the Options page (width/height overrides, - -- preset refs, border seeding, label, roster, position…) - -- rawsets onto this throwaway view and silently reverts on the - -- next layout switch / reload. `enabled` itself is exempt by - -- construction: it is rawset on the view, so __newindex never - -- fires for it — the runtime override value stays view-local - -- while HandleRuntimeWrite persists it to override/global. - viewSets[setIdx] = setmetatable( - { enabled = ov.enabled and true or false }, - { - __index = realSet, - __newindex = function(_, key, value) - realSet[key] = value - end, - }) - else - viewSets[setIdx] = realSet -- live reference - end - end - -- Top-level fields (disableInPvP, …) must equally write through to the - -- real pinnedFrames table; `sets` is rawset above so __newindex never - -- fires for it. - overlay.pinnedFrames = setmetatable({ sets = viewSets }, { - __index = realPF, - __newindex = function(_, key, value) - realPF[key] = value - end, - }) - end - - -- Activate the overlay (proxy reads this automatically) - DF.raidOverrides = overlay - - -- Count overlay keys for debug - local overlayCount = 0 - for _ in pairs(overlay) do overlayCount = overlayCount + 1 end - local totalOverrides = 0 - for _ in pairs(profile.overrides) do totalOverrides = totalOverrides + 1 end - DF:Debug("AUTOPROFILE", "ApplyRuntimeProfile: overlay active — %d overrides, %d overlay keys", totalOverrides, overlayCount) - - -- Store active state - self.activeRuntimeProfile = profile - self.activeRuntimeContentKey = contentKey - - -- Refresh all frames to reflect new settings - if DF.FullProfileRefresh then - DF:FullProfileRefresh() - end - - -- Chat notification - local raidSize = GetNumGroupMembers() - local contentName = GetContentDisplayName(contentKey) - DF:Say(format(L["Auto-profile \"%s\" activated (%s, %d players)"], profile.name or L["Unnamed"], contentName, raidSize)) - - -- Update tab override stars - self:RefreshTabOverrideStars() - - -- A layout going active greys the toolbar Unlock (it gates on IsLayoutActive); - -- refresh it if the GUI is open so the button state isn't stale. - if DF.GUI and DF.GUI.UpdateLockButtonState then DF.GUI.UpdateLockButtonState() end -end - --- Remove the active runtime profile overlay -function AutoProfilesUI:RemoveRuntimeProfile() - if not self.activeRuntimeProfile then return end - DF:Debug("AUTOPROFILE", "RemoveRuntimeProfile: deactivating \"%s\" (%s)", self.activeRuntimeProfile.name or "?", self.activeRuntimeContentKey or "?") - - -- Clear overlay FIRST so any subsequent refresh reads global settings - DF.raidOverrides = nil - - -- Clear runtime state - self.activeRuntimeProfile = nil - self.activeRuntimeContentKey = nil - - -- Snap frames to base settings immediately so they are never in a - -- partially-configured state between overlay clear and the caller's refresh - if not InCombatLockdown() and DF.FullProfileRefresh then - DF:FullProfileRefresh() - end - - DF:Say(L["Auto-profile deactivated, using global settings"]) - self:RefreshTabOverrideStars() - - -- A layout deactivating must re-enable the toolbar Unlock — otherwise it stays - -- greyed and its click wrongly steers to the Auto Layouts page (stuck state). - if DF.GUI and DF.GUI.UpdateLockButtonState then DF.GUI.UpdateLockButtonState() end -end - --- Write a raid position straight into the ACTIVE layout's override + live overlay, --- for direct world drags (the permanent mover) that shouldn't open the full editing --- GUI. Returns true when it handled the write (a layout is active and not being --- edited) so the caller skips the base write; false otherwise (no layout / editing --- — the caller writes base, or the editing-preview path captures it on lock). --- Mirrors what the toolbar Unlock flow persists, minus the snapshot/diff machinery --- (a single scalar position key needs none — ApplyRuntimeProfile rebuilds the --- overlay from profile.overrides on the next layout re-eval). -function AutoProfilesUI:SetActiveLayoutRaidPosition(x, y) - if self:IsEditing() then return false end - local p = self.activeRuntimeProfile - if not p then return false end - p.overrides = p.overrides or {} - p.overrides.raidAnchorX = x - p.overrides.raidAnchorY = y - -- Reflect immediately so GetRaidDB() (overlay view) returns the new value. - if DF.raidOverrides then - DF.raidOverrides.raidAnchorX = x - DF.raidOverrides.raidAnchorY = y - end - if DF.UpdateRaidContainerPosition then DF:UpdateRaidContainerPosition() end - return true -end - --- ============================================================ --- RUNTIME WRITE INTERCEPTION --- When a user changes a global setting via GUI while an auto-profile --- overlay is active, the proxy __newindex already writes to the real --- table. This function tells the caller whether the key is overridden --- so the GUI can suppress visual refresh (the overlay value stays visible). --- ============================================================ - --- Intercept a GUI control write for an overridden key. --- The proxy __newindex already wrote the user's value to the real table. --- For nested keys we also need to update the real sub-table manually. --- Returns true if the key is overridden (caller should skip frame refresh). -function AutoProfilesUI:HandleRuntimeWrite(key, value) - if not key then return false end - -- While EDITING a layout, the write must flow to the normal setter + - -- SetProfileSetting so it persists into the layout's override (and previews - -- live). Runtime protection is only for non-editing runtime changes — - -- otherwise it steals the edit and it's lost on reload (e.g. My Group First). - -- Mirrors the WrapDB __newindex guard, which also bows out when IsEditing(). - if self:IsEditing() then return false end - if not self.activeRuntimeProfile or not DF.raidOverrides then return false end - - -- Check if this key (or its parent) is covered by the overlay - local setIndex, setting = ParsePinnedKey(key) - if setIndex and setting then - local pf = DF._realRaidDB.pinnedFrames - if pf and pf.sets and pf.sets[setIndex] then - pf.sets[setIndex][setting] = value - end - -- Post-decouple, `enabled` is the ONLY layout-overridable pinned key - -- (PINNED_OVERRIDABLE). Every other pinned setting reads through the - -- overlay view to the real table, so the value just written IS the live - -- effective value — the caller must apply/refresh it normally. Treating - -- the whole pinnedFrames overlay as "overridden" suppressed callbacks - -- for ALL pinned edits whenever any set had an Enable override. - local isOverridden = false - if setting == "enabled" then - local overlayPF = DF.raidOverrides.pinnedFrames - local overlaySet = overlayPF and overlayPF.sets and overlayPF.sets[setIndex] - -- Only sets carrying an enabled override become view PROXIES (with - -- a metatable); sets without one are the real table by reference. - isOverridden = overlaySet ~= nil and getmetatable(overlaySet) ~= nil - end - DF:Debug("AUTOPROFILE", "HandleRuntimeWrite: pinned key %s — overridden=%s, wrote to real table", key, tostring(isOverridden)) - return isOverridden - end - - local tableName, index = ParseTableKey(key) - if tableName and index then - local tbl = DF._realRaidDB[tableName] - if type(tbl) == "table" then - tbl[index] = value - end - local isOverridden = DF.raidOverrides[tableName] ~= nil - DF:Debug("AUTOPROFILE", "HandleRuntimeWrite: table key %s — overridden=%s, wrote to real table", key, tostring(isOverridden)) - return isOverridden - end - - -- Simple key: write to real table so user changes persist in the global profile - if DF.raidOverrides[key] ~= nil then - DF._realRaidDB[key] = value - DF:Debug("AUTOPROFILE", "HandleRuntimeWrite: %s — overridden, wrote value=%s to global", key, tostring(value)) - return true - end - return false -end - --- Check if a setting key is currently overridden by an active runtime profile. --- Used by the GUI to show visual indicators on overridden controls. -function AutoProfilesUI:IsOverriddenByRuntime(key) - if not self.activeRuntimeProfile or not DF.raidOverrides then return false end - - local setIndex, setting = ParsePinnedKey(key) - if setIndex and setting then - -- Compare the specific setting value in the overlay against _realRaidDB. - -- The overlay always has a pinnedFrames copy when any layout is active; - -- only per-setting differences indicate an actual override. - local overlayPF = DF.raidOverrides.pinnedFrames - local realPF = DF._realRaidDB and DF._realRaidDB.pinnedFrames - if not overlayPF or not realPF then return false end - local overlaySet = overlayPF.sets and overlayPF.sets[setIndex] - local realSet = realPF.sets and realPF.sets[setIndex] - if not overlaySet or not realSet then return false end - return not DeepCompare(overlaySet[setting], realSet[setting]) - end - - local tableName, index = ParseTableKey(key) - if tableName and index then - return DF.raidOverrides[tableName] ~= nil - end - - return DF.raidOverrides[key] ~= nil -end - --- Get the global (baseline) value of an overridden key for display in indicators. --- With the overlay proxy the real table is always clean, so just read from it. -function AutoProfilesUI:GetRuntimeGlobalValue(key) - local setIndex, setting = ParsePinnedKey(key) - if setIndex and setting then - local pf = DF._realRaidDB and DF._realRaidDB.pinnedFrames - local sets = pf and pf.sets - if sets and sets[setIndex] then - return sets[setIndex][setting] - end - return nil - end - - local tableName, index = ParseTableKey(key) - if tableName and index then - local tbl = DF._realRaidDB[tableName] - if type(tbl) == "table" then - return tbl[index] - end - return nil - end - - return DF._realRaidDB[key] -end - --- One-shot migration: strip stale "pinnedFrames" direct keys from all profile overrides. --- These were incorrectly written by the ExitEditing diff scan when a pinned frame was --- dragged during editing. The key causes the entire pinnedFrames table (including --- position) to overwrite _realRaidDB on the next EnterEditing, moving the frame. -local dfAutoProfilesMigrated = false -local function MigrateAutoProfileOverrides() - if dfAutoProfilesMigrated then return end - dfAutoProfilesMigrated = true - - local autoDb = DF.db and DF.db.raidAutoProfiles - if not autoDb then return end - - local cleaned = 0 - local contentTypes = { "mythic", "instanced", "openWorld" } - for _, ct in ipairs(contentTypes) do - local profiles = autoDb[ct] and autoDb[ct].profiles - if profiles then - for _, profile in ipairs(profiles) do - if profile.overrides and profile.overrides.pinnedFrames ~= nil then - profile.overrides.pinnedFrames = nil - cleaned = cleaned + 1 - end - end - end - -- Also check single-profile (mythic uses .profile not .profiles) - local single = autoDb[ct] and autoDb[ct].profile - if single and single.overrides and single.overrides.pinnedFrames ~= nil then - single.overrides.pinnedFrames = nil - cleaned = cleaned + 1 - end - end - - if cleaned > 0 then - DF:Debug("AUTOPROFILE", "MigrateAutoProfileOverrides: removed stale pinnedFrames key from %d profile(s)", cleaned) - end -end - --- Evaluate current content/raid state and apply/remove profiles as needed -function AutoProfilesUI:EvaluateAndApply() - if not DF.initialized then return end - if self:IsEditing() then - DF:Debug("AUTOPROFILE", "EvaluateAndApply: skipped (editing mode)") - return - end - - MigrateAutoProfileOverrides() - - -- Cannot modify secure frames during combat — queue for later - if InCombatLockdown() then - DF:Debug("AUTOPROFILE", "EvaluateAndApply: in combat, queued for PLAYER_REGEN_ENABLED") - self.pendingAutoProfileEval = true - return - end - - -- Determine what profile should be active - local newProfile, contentKey = self:GetActiveProfile() - local oldName = self.activeRuntimeProfile and self.activeRuntimeProfile.name or "none" - local newName = newProfile and newProfile.name or "none" - - -- No change — same profile (or both nil) - if newProfile == self.activeRuntimeProfile then - DF:Debug("AUTOPROFILE", "EvaluateAndApply: no change (active=\"%s\")", oldName) - return - end - if newProfile == nil and self.activeRuntimeProfile == nil then return end - - DF:Debug("AUTOPROFILE", "EvaluateAndApply: switching \"%s\" -> \"%s\"", oldName, newName) - DF:Debug("RAIDPOS", "AutoProfile SWITCH: \"%s\" -> \"%s\" (raid size=%d)", oldName, newName, GetNumGroupMembers()) - - -- Capture whether an old profile was active before clearing state - local oldWasActive = (self.activeRuntimeProfile ~= nil) - - -- Remove old profile if one was active (clears overlay and state only) - if self.activeRuntimeProfile then - self:RemoveRuntimeProfile() - end - - -- Apply new profile if one matches (sets overlay then calls FullProfileRefresh: 1 refresh) - if newProfile then - self:ApplyRuntimeProfile(newProfile, contentKey) - elseif oldWasActive then - -- Profile deactivated (profile→none): refresh once with clean global settings - DF:FullProfileRefresh() - end -end - --- ============================================================ --- EVENT FRAME & THROTTLE --- Listens for content/roster changes and triggers evaluation --- ============================================================ - -local autoProfileEventFrame = CreateFrame("Frame") -autoProfileEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE") -autoProfileEventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA") -autoProfileEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") -autoProfileEventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") - --- Frame-based throttle: multiple events in the same frame collapse into one evaluation -local autoProfileThrottleFrame = CreateFrame("Frame") -autoProfileThrottleFrame:Hide() -autoProfileThrottleFrame:SetScript("OnUpdate", function(self) - self:Hide() - - -- Suppress any pending roster update — we process it after overlay is settled - local rosterPending = DF._rosterThrottleFrame and DF._rosterThrottleFrame:IsShown() - if rosterPending then - DF._rosterThrottleFrame:Hide() - end - - DF:Debug("RAIDPOS", "AutoProfile throttle FIRE: rosterPending=%s combat=%s", tostring(rosterPending), tostring(InCombatLockdown())) - - AutoProfilesUI:EvaluateAndApply() - - -- Now process roster with correct overlay state - if rosterPending then - DF:Debug("RAIDPOS", "AutoProfile throttle: running deferred ProcessRosterUpdate after EvaluateAndApply") - if not InCombatLockdown() then - DF:ProcessRosterUpdate() - else - local raidDb = DF:GetRaidDB() - if IsInRaid() and not raidDb.raidUseGroups then - DF.pendingFlatLayoutRefresh = true - else - DF.pendingHeaderSettingsApply = true - end - end - end -end) - -local function QueueAutoProfileEval() - autoProfileThrottleFrame:Show() -end - -autoProfileEventFrame:SetScript("OnEvent", function(self, event) - if not DF.initialized then return end - if event == "PLAYER_REGEN_ENABLED" then - if AutoProfilesUI.pendingAutoProfileEval then - DF:Debug("AUTOPROFILE", "Event: %s — processing queued evaluation", event) - AutoProfilesUI.pendingAutoProfileEval = false - QueueAutoProfileEval() - end - return - end - - DF:Debug("AUTOPROFILE", "Event: %s — queuing evaluation", event) - QueueAutoProfileEval() -end) - --- ============================================================ --- PRINT OVERRIDES: /df debug overrides --- Shows which settings are overridden, grouped by tab --- ============================================================ - -function AutoProfilesUI:PrintOverrides() - -- Determine which profile to report on - local profile, source - if self:IsEditing() then - profile = self.editingProfile - source = L["Editing"] - elseif self.activeRuntimeProfile then - profile = self.activeRuntimeProfile - source = L["Runtime"] - else - local activeProfile = self:GetActiveProfile() - if activeProfile then - profile = activeProfile - source = L["Matched (not applied)"] - end - end - - if not profile then - DF:Say(L["No auto-profile is currently active or being edited."]) - return - end - - if not profile.overrides or not next(profile.overrides) then - DF:Say(format(L["Profile \"%s\" has no overrides."], profile.name or L["Unnamed"])) - return - end - - -- Group by tab (also drives the total, so it matches the deduped display). - local groups, unknownKeys = GroupOverridesByTab(profile.overrides) - - -- Count total: everything shown (mapped groups + "Other"), deduped. - local total = CountDisplayedOverrides(profile.overrides) - - local o = DF:Out(L["Auto-Profile Overrides"]) - o:Field(L["Profile:"], "\"" .. (profile.name or L["Unnamed"]) .. "\" (" .. source .. ")") - o:Field(L["Total:"], format(total > 1 and L["%d overrides"] or L["%d override"], total)) - - -- Sort tab IDs for consistent output - local tabOrder = {} - for tabId in pairs(groups) do - tinsert(tabOrder, tabId) - end - table.sort(tabOrder) - - -- Global (un-overridden) raid values, to diff table overrides against so only - -- the changed leaves print — not the whole stored config. While editing, the - -- live controls write previews into DF._realRaidDB, so the TRUE global lives in - -- globalSnapshot; diffing against _realRaidDB there would show nothing changed. - local realRaid = (self:IsEditing() and self.globalSnapshot) or DF._realRaidDB or (DF.db and DF.db.raid) - local budget = { n = nil } -- chat scrolls: no line cap (depth-capped only) - -- Breadcrumb headers (val == nil) print bare; leaves print "key = value". - local function emitChat(indent, label, val) - if val == nil then - print(indent .. label) - else - print(indent .. label .. " = |cffffffff" .. val .. "|r") - end - end - -- Chat is the DIAGNOSTIC surface -- people paste it for support -- so it shows - -- the readable label AND the raw saved key, unlike the tooltip which shows the - -- label alone. - local function chatKey(key) - local label = KeyLabel(key) - if label == key then return key end - return label .. " |cff808080(" .. key .. ")|r" - end - local function printKey(key, indentBase) - local value = profile.overrides[key] - if type(value) == "table" and not (value.r and value.g and value.b) then - print(indentBase .. chatKey(key) .. ":") - WalkOverrideDiff(value, realRaid and realRaid[key], "", indentBase .. " ", 1, 8, budget, emitChat) - else - local displayValue - if type(value) == "table" then - displayValue = string.format("|cffffffff(%.2f, %.2f, %.2f)|r", value.r, value.g, value.b) - elseif type(value) == "boolean" then - displayValue = value and "|cff00ff00true|r" or "|cffff4444false|r" - else - displayValue = "|cffffffff" .. tostring(value) .. "|r" - end - print(indentBase .. chatKey(key) .. " = " .. displayValue) - end - end - - for _, tabId in ipairs(tabOrder) do - local group = groups[tabId] - print(" |cffffaa00" .. group.tabLabel .. "|r (" .. #group.keys .. "):") - table.sort(group.keys) - for _, key in ipairs(group.keys) do - printKey(key, " ") - end - end - - if #unknownKeys > 0 then - print(" |cff999999" .. L["Other"] .. "|r (" .. #unknownKeys .. "):") - table.sort(unknownKeys) - for _, key in ipairs(unknownKeys) do - printKey(key, " ") - end - end -end - --- ============================================================ --- CLEAR OVERRIDE: /df clearoverride --- Escape hatch to remove a stuck auto-layout override directly, for cases the --- normal settings UI can't reach (e.g. a pinned-players override when not in a --- raid). Targets the layout being edited, else the active/matched one — same --- resolution as /df debug overrides. --- ============================================================ -function AutoProfilesUI:ClearOverrideCommand(arg) - local profile, editing - if self:IsEditing() then - profile, editing = self.editingProfile, true - elseif self.activeRuntimeProfile then - profile = self.activeRuntimeProfile - else - profile = self:GetActiveProfile() - end - - if not profile or not profile.overrides or not next(profile.overrides) then - DF:Say(L["No auto-profile is currently active or being edited."]) - return - end - - if not arg or arg == "" then - DF:Say(L["Usage: /df clearoverride (see /df debug overrides for keys)"]) - return - end - - -- Build the list of keys to clear: "all", an exact key, or a prefix match - -- (case-insensitive). Prefix lets you clear e.g. all "pinned.1." overrides at once. - local argLower = arg:lower() - local toClear = {} - if argLower == "all" then - for k in pairs(profile.overrides) do toClear[#toClear + 1] = k end - else - for k in pairs(profile.overrides) do - local kl = k:lower() - if kl == argLower or kl:sub(1, #argLower) == argLower then - toClear[#toClear + 1] = k - end - end - end - - if #toClear == 0 then - DF:Say(format(L["No override matching \"%s\" on layout \"%s\"."], arg, profile.name or L["Unnamed"])) - return - end - - table.sort(toClear) - for _, k in ipairs(toClear) do - if editing then - -- Removes the override AND restores the live preview value to global, - -- so ExitEditing's diff won't re-store it. - self:ResetProfileSetting(k) - else - profile.overrides[k] = nil - end - end - - local o = DF:Out("Auto Profiles", format(L["Cleared %d override(s) from layout \"%s\":"], #toClear, profile.name or L["Unnamed"])) - o:More(toClear, 10) - - -- Reflect the change live. - if editing then - self:RefreshTabOverrideStars() - local GUI = DF.GUI - if GUI and GUI.InvalidateAllPages then GUI:InvalidateAllPages() end - if GUI and GUI.RefreshCurrentPage then GUI:RefreshCurrentPage() end - elseif self.EvaluateAndApply then - self:EvaluateAndApply() - end -end - --- ============================================================ --- DEBUG: Auto Profile Detection Test --- Usage: /dfautotest - Print current detection results --- ============================================================ - -DF:RegisterDebugSlash("DFAUTOTEST", "Auto profiles test harness", true, "/dfautotest") -SlashCmdList["DFAUTOTEST"] = function() - local autoDb = DF.db and DF.db.raidAutoProfiles - local enabled = autoDb and autoDb.enabled - local inRaid = IsInRaid() - local contentType = DF.GetContentType and DF:GetContentType() or "N/A" - local raidSize = GetNumGroupMembers() - - local o = DF:Out("Auto Profiles", "detection") - -- "Not in a raid" is the normal state outside one, so it reads NEUTRAL. Only - -- the feature being switched off is worth flagging, and only as a warning. - o:Field("Enabled", enabled and "yes" or "no", enabled and "good" or "warn") - o:Field("In raid", inRaid and "yes" or "no", inRaid and "good" or "neutral") - o:Field("Content type", contentType) - o:Field("Raid size", raidSize) - - local profile, profileKey = AutoProfilesUI:GetActiveProfile() - o:Section("Active profile") - if profile then - local overrideCount = 0 - if profile.overrides then - for _ in pairs(profile.overrides) do - overrideCount = overrideCount + 1 - end - end - o:Field("Name", profile.name or "Unnamed", "good") - o:Field("Matched key", profileKey) - if profile.min and profile.max then - o:Field("Range", profile.min .. "-" .. profile.max .. " players") - end - o:Field("Overrides", overrideCount) - else - o:Line("none - using global settings", "neutral") - end - - o:Section("Runtime state") - local rtProfile = AutoProfilesUI.activeRuntimeProfile - if rtProfile then - local rtOverrides = 0 - if rtProfile.overrides then - for _ in pairs(rtProfile.overrides) do rtOverrides = rtOverrides + 1 end - end - local overlayKeys = 0 - if DF.raidOverrides then - for _ in pairs(DF.raidOverrides) do overlayKeys = overlayKeys + 1 end - end - o:Field("Runtime profile", (rtProfile.name or "Unnamed") - .. " (" .. tostring(AutoProfilesUI.activeRuntimeContentKey) .. ")", "good") - o:Field("Applied overrides", rtOverrides) - o:Field("Overlay keys", overlayKeys) - else - o:Field("Runtime profile", "none", "neutral") - end - -- A deferred evaluation is a WARN: settings the user expects are not applied yet. - o:Field("Pending combat eval", AutoProfilesUI.pendingAutoProfileEval and "yes" or "no", - AutoProfilesUI.pendingAutoProfileEval and "warn" or "neutral") - o:Field("Editing mode", AutoProfilesUI:IsEditing() and "yes" or "no", - AutoProfilesUI:IsEditing() and "warn" or "neutral") - - -- Also list all configured profiles for context - if autoDb then - local configured = {} - for _, ctDef in ipairs(CONTENT_TYPES) do - local key = ctDef.key - if key == "mythic" then - local mp = autoDb.mythic and autoDb.mythic.profile - if mp then - configured[#configured + 1] = { "Mythic", (mp.name or "Unnamed") .. " (20 fixed)" } - end - else - local profiles = autoDb[key] and autoDb[key].profiles or {} - for _, p in ipairs(profiles) do - configured[#configured + 1] = { ctDef.title, p.name .. " (" .. p.min .. "-" .. p.max .. ")" } - end - end - end - o:Section("Configured profiles", #configured) - for _, row in ipairs(configured) do - o:Item(row[1], row[2]) - end - end -end From 621cdd21cb2beaea5ffa1cefc01479bd2c65f4ea Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:46:32 +0100 Subject: [PATCH 22/34] README: document the second junction and the resident-behaviour rule The companion exists now, so the setup section needs both junctions rather than describing it as future work. Also states the rule the last few commits were all instances of: live behaviour must not depend on the companion being loaded. --- README.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 73eea861..992f385f 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ folder in with a **directory junction** — a junction (`/J`), not a symlink From your `Interface/AddOns` folder, with WoW closed: ``` -mklink /J "DandersFrames" "C:\path\to\repo\DandersFrames" +mklink /J "DandersFrames" "C:\path\to\repo\DandersFrames" +mklink /J "DandersFrames_Options" "C:\path\to\repo\DandersFrames_Options" ``` Delete any real `AddOns/DandersFrames` folder first — a junction cannot @@ -79,21 +80,29 @@ After that, edit in the repo and `/reload` in game; the junction means there is no copy or sync step. `git status`, branches and PRs are unchanged — one repo, one branch, one PR. -### Why a container, when there is only one addon folder +### Why a container, rather than one addon folder -Groundwork for a load-on-demand companion, `DandersFrames_Options`, holding -the settings panel, the designers and the debug tools. WoW resolves -`LoadAddOn("Name")` to `AddOns/Name/Name.toc`, so a companion has to be a -top-level addon folder — a sibling of `DandersFrames`, not a subfolder. That -is only possible if the repo root is a container, hence this layout. +`DandersFrames_Options` is a load-on-demand companion holding the settings +panel, both designers, the click-casting UI, test mode and the debug tools — +30 files, about a third of the code. WoW resolves `LoadAddOn("Name")` to +`AddOns/Name/Name.toc`, so a companion has to be a top-level addon folder — a +sibling of `DandersFrames`, not a subfolder. That is only possible if the repo +root is a container, hence this layout. Measured on the PTR build: with that payload not loaded, DandersFrames uses **8,957 KB instead of 12,348 KB — 3,391 KB less, 27.5%** — and ~55,000 lines are never parsed at login. The saving lasts until the settings panel is opened; the login cost is avoided every time. -**The companion does not exist yet.** When it does, it gets a second junction -alongside the first, and this section will say so. +Users do none of this: the packager ships the two folders as siblings in the +zip, and `.pkgmeta` maps them. The junctions exist only because there is no +packager running locally. + +**Nothing may depend on the companion to work.** Live behaviour that happened +to live in a settings file has to move back to the main addon, not be guarded +away — the auto-profile engine, the click-casting bootstrap and the aura +migrations are all resident for that reason. `docs/reorg-tools/lod_login_check.py` +catches the common form of this. ### Verification tooling From fc31e7cce18213c766c551435f204cfe033e8bed Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 20:59:54 +0100 Subject: [PATCH 23/34] Move settings-only widgets out of the resident GUI toolkit 30 widget factories the live addon never calls -- 2,483 lines -- move from GUI.lua and Widgets.lua into DandersFrames_Options/GUI/SettingsWidgets.lua. The biggest are CreateBorderControls, CreateDesignerPresetBar, CreateColorPicker and CreateAnimationControls: settings-panel furniture with no path from live rendering. What stays is what live code actually touches -- the movers, the settings search box, tooltips, and the styling primitives (StyleButton, CreateElementBackdrop, CreateSlider, CreateDropdown). CreateSlider and CreateDropdown stay only because Frames/Position builds the mover panel with them; that is 818 lines that cannot move while the mover panel is resident. Deciding what could move was the whole job, and the obvious rule is wrong: - "nothing outside the toolkit calls it" over-counts, because a lot of the hits are in COMMENTS. Stripping comments first moved RelayoutHost and PromptName from the keep list to the move list and back again. - Closing only over other GUI: functions is not enough. A staying function reaches a moving one through the file's own local helpers too, which is invisible to that closure. The real rule is that the REMAINDER, all of it, must not mention anything that left. Iterating that pulled five functions back -- AttachTooltip, RegisterMenu, PromptName, CreateOverrideMarker and CreateOverrideResetButton -- and without it the first three would have been nil at runtime. Five file locals are newly published on GUI._priv for the new file. None is reassigned after publication, so aliasing them is safe; that was checked, not assumed, after CONTENT_TYPES turned out to be reassigned in the AutoProfiles split. ApplyPixelBorder and HidePixelBorder (57 lines) are referenced nowhere at all. Left alone rather than moved or deleted -- flagging separately. Verified: both resident files reconstruct byte-identically from the remainder plus the extracted blocks; no toolkit local is used in the new file without an alias; the resident toolkit no longer calls anything that moved; no unused aliases in the new preamble; all 114 files parse; TOC complete on both sides; load order 0 violations; alias_check clean on all four split sets. lod_gate_check now checks the GUI namespace as well as DF -- the toolkit is reached as GUI.X, so the DF arm could never see it. It immediately found 16 UNGUARDED resident calls into companion-owned GUI methods, all pre-dating this commit: GUI:ShowTooltip (15 sites, including pinned frames and the mover panel) and GUI:CreateInfoBanner. The original toolkit split put ShowTooltip in the companion while leaving HideTooltip resident. Fixed next; the checker is correct to be red until then. --- DandersFrames/GUI/GUI.lua | 3581 ++++++++--------- DandersFrames/GUI/Widgets.lua | 2121 +--------- .../DandersFrames_Options.toc | 3 + DandersFrames_Options/GUI/SettingsWidgets.lua | 2545 ++++++++++++ 4 files changed, 4167 insertions(+), 4083 deletions(-) create mode 100644 DandersFrames_Options/GUI/SettingsWidgets.lua diff --git a/DandersFrames/GUI/GUI.lua b/DandersFrames/GUI/GUI.lua index f9b55c9b..96ca8d4b 100644 --- a/DandersFrames/GUI/GUI.lua +++ b/DandersFrames/GUI/GUI.lua @@ -1,1972 +1,1609 @@ -local addonName, DF = ... -local GUI = {} -DF.GUI = GUI -local L = DF.L - --- Mutable module state. --- These four were file-scope locals, and two of them are read and written more --- than 6,000 lines apart. That is harmless in one file and silently broken the --- moment the file is split: a `local` re-declared in the second half becomes an --- independent variable, so the two halves stop sharing state with no error. --- Holding them on one table keeps a future split honest -- each part reads the --- same table via `local S = GUI._state`. Read-only aliases (the C_* colours, L, --- GUI itself) do not need this: re-declaring those yields the same value. --- Private by convention -- nothing outside this file should touch GUI._state. -local S = {} -GUI._state = S - --- Shared private helpers. --- A `local function` is invisible to any other file, so a helper used thousands --- of lines from its declaration pins this file together: it cannot be split --- while a later section still calls it. Publishing the wide-reaching ones here --- lets a sibling file re-declare `local X = GUI._priv.X` -- the same function --- object, so no call site changes and behaviour is identical. --- Only helpers spanning >1000 lines are listed; narrow ones stay private. --- Private by convention -- nothing outside GUI/ should touch GUI._priv. -local P = {} -GUI._priv = P - --- ========================================================================= --- MODERN UI CONSTANTS & STYLING (Matching Original v2.3.8) --- ========================================================================= - -local C_BACKGROUND = {r = 0.08, g = 0.08, b = 0.08, a = 0.95} -- Dark charcoal -local C_PANEL = {r = 0.12, g = 0.12, b = 0.12, a = 1} -- Slightly lighter -local C_ELEMENT = {r = 0.18, g = 0.18, b = 0.18, a = 1} -- Element backgrounds -local C_BORDER = {r = 0.25, g = 0.25, b = 0.25, a = 1} -- Subtle borders -local C_ACCENT = {r = 0.45, g = 0.45, b = 0.95, a = 1} -- Party Purple-Blue -local C_RAID = {r = 1.0, g = 0.5, b = 0.2, a = 1} -- Raid Orange -local C_HOVER = {r = 0.22, g = 0.22, b = 0.22, a = 1} -local C_TEXT = {r = 0.9, g = 0.9, b = 0.9, a = 1} -local C_TEXT_DIM = {r = 0.6, g = 0.6, b = 0.6, a = 1} -local C_WARNING = {r = 0.95, g = 0.35, b = 0.35, a = 1} -- Soft red: behaviour-change / caution notes - --- Exported palette: other files should theme against these shared tables instead --- of re-declaring private copies or hardcoding the raw numbers. These are the --- SAME table references as the locals above. For the mode-aware accent, use --- GUI.GetThemeColor() (returns party purple or raid orange). -GUI.Colors = { - background = C_BACKGROUND, - panel = C_PANEL, - element = C_ELEMENT, - border = C_BORDER, - accent = C_ACCENT, -- party purple - raid = C_RAID, -- raid orange - hover = C_HOVER, - text = C_TEXT, - textDim = C_TEXT_DIM, - warning = C_WARNING, -- soft red for behaviour-change / caution notes -} - --- Dialog chrome. Popup.lua is a standalone dialog rather than a settings page and --- wanted the same handful of extras on top of the shared neutrals — so it had --- grown a private copy of the WHOLE palette (11 hardcoded colours), matching --- today only by luck. (WizardBuilder.lua was the other such dialog, since --- deleted as dead code — hence "three separate copies" in the note below.) --- One owner: the neutrals below are the SAME tables as GUI.Colors, so they --- theme-track in lockstep, and only what genuinely differs is declared here. --- Read-only by convention — these tables are shared, so nothing may mutate them. -GUI.DialogColors = { - -- Dialogs use the SAME ground as the pages. This was a bespoke 0.97 in three - -- separate copies, a shade denser than the pages' 0.95 for no reason anyone - -- could point at; consolidating the copies made the difference visible and - -- it went. Chrome now reads identically whether it's a page or a dialog. - background = C_BACKGROUND, - panel = C_PANEL, - element = C_ELEMENT, - border = C_BORDER, - accent = C_ACCENT, -- fallback only; live dialogs read GetThemeColor() - hover = C_HOVER, - text = C_TEXT, - textDim = C_TEXT_DIM, - selected = {r = 0.28, g = 0.28, b = 0.45, a = 1}, - -- Status pair for dialog content (valid/invalid rows, ok/error dots). - green = {r = 0.2, g = 0.9, b = 0.2}, - red = {r = 0.9, g = 0.25, b = 0.25}, - orange = {r = 0.85, g = 0.55, b = 0.1}, -} - --- Canonical row heights (the "airier" scale). A fixed-height widget factory stamps its own slot --- height onto the widget (widget.preferredHeight + widget.fixedRowHeight), so the layout uses THAT --- and a call-site number can't make the same widget type render at a different height on a different --- page. New callers can omit the height entirely; legacy call-site numbers on fixed widgets are --- ignored (harmless, strippable later). Variable widgets (labels, headers, spacers) are NOT stamped --- and keep whatever height they are given. One place to retune the whole GUI's vertical rhythm. --- THE vertical rhythm of the whole GUI. --- --- These are SLOT heights, not gaps -- LayoutChildren stacks rows flush --- (y = y - height), so the gap the eye sees between two rows is: --- --- (slot - content) of the row above + (content's top inset) of the row below --- --- which means a row whose content is short inside a tall slot silently gets a big --- gap. That is how the GUI ended up with a 4x spread. /df debug gapcheck measured it --- across four pages (267 rows), and the content heights came back IDENTICAL on --- every page, so the slots can be derived rather than guessed: --- --- kind content old slot old gap new slot new gap --- slider 32.0 55 23.0 46 14 --- dropdown 39.8 55 15.2 54 14 --- editbox 39.0 55 16.0 53 14 --- colorpicker 23.9 30 6.1 38 14 --- checkbox 18.2 30 11.8* 35 14* --- header 11.9 34 / 40 11.0 / 17.0 37 14 --- --- So: ONE gap, and every slot is content + RowGap. 14 is not arbitrary -- it is --- what the dropdown rows already had (15.2), the one spacing Krathe confirmed --- reads correctly. Sliders lose 9px of slack, colour pickers gain 8. --- --- * the checkbox's content sits 2.9px below its slot top, so a row landing ON a --- checkbox reads 14 + 2.9. Zeroing that would mean re-anchoring the checkbox --- art itself, which moves the tick 3px for 3px -- not worth it. --- --- A header keeps its 11.1px top inset, so the gap ABOVE a header is 14 + 11.1. --- That is deliberate: a section title wants air above it and to sit close to --- what it labels. -GUI.RowGap = 14 - --- ...with ONE exception: a RUN of the same COMPACT row type closes up. --- --- A uniform gap everywhere is right between DIFFERENT kinds -- that is the --- boundary the eye uses to tell one control apart from the next. But eight --- checkboxes in a column are one list, not eight things, and 14 between each of --- them reads as a stack of unrelated rows. So consecutive rows of the same --- compact kind get RowGapTight, and the first row of a different kind after them --- gets the full RowGap back. Same spacing between TYPES, tighter within a type. --- --- Compact means the label sits INLINE with the control (a checkbox's text is --- beside its tick). Slider, dropdown and edit box are deliberately NOT compact: --- their label sits ABOVE the control, so tightening the gap there would push the --- next row's label into the control above it -- the same reasoning already --- recorded on labelPad, and the reason a stack of sliders needs real air even --- though a stack of checkboxes does not. -GUI.RowGapTight = 8 -GUI.RowCompact = { - checkbox = true, - toggle = true, - colorpicker = true, -} - --- PAGE-level spacing, a different axis from the row rhythm above: AddSpace --- inserts a spacer into the page's COLUMN flow, between groups, not between rows --- inside one. Groups already carry a 10px margin of their own, so these stack ON --- TOP of that -- a `section` break reads as 20 between two groups, a `block` as --- 30. --- --- The audit found 58 AddSpace calls passing 9 different numbers, which looked --- worse than it was: classified by INTENT rather than by value, two idioms cover --- 42 of them and both were already internally consistent -- --- --- section break (after `currentSection = nil`, or a bare gap after a group) --- 19/19 at 10, plus 7 more following an Add() --- before the See-Also links at the foot of a page --- 15/16 at 20, one stray 15 --- --- The real inconsistency was three files each picking their own number for the --- SAME intent (NicknamesPage used 12 throughout), not 9 competing rhythms. -GUI.Space = { - section = 10, -- between logical sections in a page column - block = 20, -- before a distinct trailing block (the See-Also links) - footer = 12, -- below the See-Also bar when it is parked at the viewport bottom -} - -GUI.RowHeight = { - checkbox = 35, -- 2.9 top inset + 18.2 content + RowGap - slider = 46, -- 32.0 content + RowGap (was 55: a slot sized for the dropdown) - dropdown = 54, -- 39.8 content + RowGap - colorpicker = 38, -- 23.9 content + RowGap - editbox = 53, -- 39.0 content + RowGap (box at -15, h24) - toggle = 35, -- two-state switch; same content as a checkbox, same row - -- Labels are VARIABLE height (they wrap), so they have no fixed row — but they do - -- have fixed CHROME, which CreateLabel adds to the measured text height: the 5px top - -- inset its FontString sits at, plus the gap below. That gap IS the whole visible - -- space to the next row — LayoutChildren stacks rows flush (y = y - height) and a - -- labelled control (dropdown/slider/editbox) puts its own label at TOPLEFT 0,0 — so a - -- smaller pad reads as a blurb crowding the control it describes. - labelPad = 5 + GUI.RowGap, - -- EVERY section header, collapsible or not. CreateHeader's container is 25 - -- tall with its text pinned to the BOTTOM, so its 11.1px of internal padding - -- all sits ABOVE the text and this slot minus 25 is the entire visible gap - -- below it -- at exactly 25 there is none. - -- - -- The old split was never designed: collapsible groups were handed 25 (no - -- gap) and plain ones 40, across both files, for the same construct. Rather - -- than sweep ~200 call sites, CreateHeader now marks itself fixedRowHeight, - -- so ResolveRowHeight IGNORES the literal a call site passes and every header - -- lands here. That is the same rule the other factory rows already follow. - sectionHeader = 11.1 + 11.9 + GUI.RowGap, -- top inset + text + the gap -} - --- Resolve the layout slot height for a widget being added to a group/page. Fixed-height widgets --- own their height (drift-proof); everything else uses the height it was handed, then the widget's --- own preferred height, then a sane default. -local function ResolveRowHeight(widget, height) - if widget and widget.fixedRowHeight and widget.preferredHeight then - return widget.preferredHeight - end - return height or (widget and widget.preferredHeight) or 55 -end -GUI.ResolveRowHeight = ResolveRowHeight - --- Sync a widget's slot height into its host SettingsGroup and re-flow. Used by any --- widget that only learns its true height AFTER construction (a measured label, an --- info banner): update the group's stored entry, re-lay out the group, then bubble to --- the page so sibling groups in the same column re-anchor to the group's new bottom. --- Without the bubble a grown group's backdrop overshoots the next group's anchor and --- renders as an empty rectangle of backdrop above it. -function GUI:RelayoutHost(widget, slotHeight) - if not widget then return end - local g = widget.settingsGroup - -- RETIRED widgets must not re-flow anything. A measured label arms a next-frame - -- converge; if the page rebuilds first (any Refresh — e.g. flipping the Colours - -- page's Seconds/Percent tabs), that timer still fires against the PREVIOUS build. - -- Its group is by then parented to the trash frame with its anchors cleared, so - -- re-laying it out sizes children off a dead frame, and the parent walk below still - -- reaches the LIVE page and makes it re-lay out mid-flight. IsShown() does not catch - -- this — a frame keeps its own shown flag when an ancestor is hidden — so test the - -- ancestry instead. - local trash = GUI._trashFrame - if trash then - local p = (g or widget) - while p do - if p == trash then return end - p = p:GetParent() - end - end - if g and g.LayoutChildren then - for _, entry in ipairs(g.groupChildren or {}) do - if entry.widget == widget then - entry.height = slotHeight - break - end - end - g:LayoutChildren() - end - local p = (g or widget):GetParent() - while p do - if type(p.RefreshStates) == "function" and p.children then - p:RefreshStates() - return - end - p = p:GetParent() - end -end - --- "/df debug guiwidth" — width ground truth for the page on screen: every top-level child and --- every settings-group child with its live width, flagging any that is non-positive or --- narrower than its group allows. --- Use it to TELL APART the two causes of truncated label text, which look identical: --- * a real width fault -> frames show up flagged here; --- * a stale FontString wrap -> every width reads correct and the count is 0, yet the --- text is visibly ellipsised (and scrolling the window snaps it back, because that --- re-renders the string). That is CreateLabel's Reflow case, not a sizing bug. --- Recorded because the second one cost two wrong fixes before this dump ruled out the first. -function GUI:DebugDumpWidths() - local page = GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] - if not page or not page.children then - DF:Say("GUI width", "no built page on screen", "WARN") - return - end - local content = GUI.contentFrame and GUI.contentFrame:GetWidth() or -1 - local o = DF:Out("GUI Width", ("page '%s'"):format(GUI.CurrentPageName)) - o:Section("Widths") - o:Field("content frame", ("%.0f"):format(content), content > 0 and "GOOD" or "BAD") - o:Field("scroll child", ("%.0f"):format(page.child and page.child:GetWidth() or -1), "NEUTRAL") - - o:Section("Widgets") - local bad = 0 - local function flag(w, limit) - if not w or w <= 0 then bad = bad + 1; return "|cffff4444 <== NON-POSITIVE|r" end - if limit and w < limit - 1 then bad = bad + 1; return "|cffffcc00 <== NARROW|r" end - return "" - end - for i, widget in ipairs(page.children) do - if widget.isSettingsGroup then - local gw = widget:GetWidth() or 0 - local inner = gw - (widget.padding or 10) * 2 - print((" [%d] group w=%.0f inner=%.0f col=%s shown=%s%s") - :format(i, gw, inner, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(gw))) - for j, entry in ipairs(widget.groupChildren or {}) do - local c = entry.widget - local cw = c and c:GetWidth() or 0 - print((" (%d) w=%.0f h=%.0f shown=%s%s") - :format(j, cw, entry.height or -1, tostring(c and c:IsShown()), flag(cw, inner))) - end - else - local w = widget:GetWidth() or 0 - print((" [%d] widget w=%.0f col=%s shown=%s%s") - :format(i, w, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(w))) - end - end - o:Section("Result") - o:Field("suspect frames", bad, bad > 0 and "WARN" or "GOOD") - o:Siblings("guiwidth") -end - -DF.SectionRegistry = DF.SectionRegistry or {} - - --- Track selected mode -GUI.SelectedMode = "party" - --- Registry of tabs that should show a "New" badge until opened. --- Add tab IDs here for new features; the badge auto-hides once viewed. --- Reset each release cycle to the tabs that are new since the last stable --- (prior entries are persisted as seen and would otherwise show stale badges). -GUI.NewTabs = { - ["text_designer"] = true, - ["general_nicknames"] = true, -} - --- Registry of section headers (inside a tab) that should show a "New" badge --- until the user visits the tab and then navigates away. Keyed by --- "." so entries are unambiguous across tabs. --- The badge is created by GUI:AddSectionNewBadge and cleared by SelectTab --- when the user leaves the owning tab (persisted via seenSections). -GUI.NewSections = { -} - --- Live-tracked badges pending a "seen" mark, keyed by tabName → { key = badge }. --- Populated by AddSectionNewBadge, drained by SelectTab on tab leave. -GUI.pendingSectionBadges = {} - --- Add a gold "New" badge to the right of a section header's text. Returns the --- badge FontString, or nil if the section isn't registered in NewSections or --- has already been marked seen. The badge clears (and is persisted as seen) --- the next time the user navigates away from `tabName`. -function GUI:AddSectionNewBadge(widget, tabName, sectionId) - -- Anchor to whichever label FontString the widget exposes: - -- * CreateHeader containers use `.text` - -- * CreateDropdown containers use `.label` - local anchor = widget and (widget.text or widget.label) - if not anchor or not tabName or not sectionId then return end - local key = tabName .. "." .. sectionId - if not GUI.NewSections[key] then return end - - local seen = DandersFramesDB_v2 and DandersFramesDB_v2.seenSections - and DandersFramesDB_v2.seenSections[key] - if seen then return end - - local badge = widget:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - badge:SetPoint("LEFT", anchor, "RIGHT", 6, 0) - badge:SetText(L["New"]) - badge:SetTextColor(1, 0.82, 0) - - GUI.pendingSectionBadges[tabName] = GUI.pendingSectionBadges[tabName] or {} - GUI.pendingSectionBadges[tabName][key] = badge - return badge -end - --- Pages that remain fully accessible regardless of whether party or raid --- mode is disabled via General settings. All other mode-specific tabs --- are greyed out and non-interactive when viewing a disabled mode. --- Auto Layouts is intentionally NOT whitelisted: it edits per-profile --- settings that would have no effect if all frames are disabled. -GUI.AlwaysAccessiblePages = { - ["general_settings"] = true, -- the toggles themselves - ["profiles_manage"] = true, - ["profiles_importexport"] = true, - ["debug_console"] = true, - ["indicators_targetedlist"] = true, - ["indicators_personal_targeted"] = true, -} - --- Returns true if the given tab should be disabled for the currently --- selected mode (i.e. the tab is mode-specific and that mode is off). -function GUI:IsTabDisabledForCurrentMode(tabName) - if not tabName then return false end - if GUI.AlwaysAccessiblePages[tabName] then return false end - if GUI.SelectedMode == "party" and DF.db and DF.db.partyEnabled == false then return true end - if GUI.SelectedMode == "raid" and DF.db and DF.db.raidEnabled == false then return true end - return false -end - --- Walk all registered tabs and update their .disabled flag + visuals --- based on the current mode and enable flags. Call after mode switches. -function GUI:UpdateTabAvailability() - if not GUI.Tabs then return end - for name, btn in pairs(GUI.Tabs) do - local disabled = GUI:IsTabDisabledForCurrentMode(name) - btn.disabled = disabled - if btn.Text then - if disabled then - btn.Text:SetTextColor(0.45, 0.45, 0.45) - else - btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - end - end - if disabled and not btn.isActive then - btn:SetBackdropColor(0, 0, 0, 0) - end - end - - -- Refresh the sidebar so party-only tabs (e.g. Visibility) hide/show for - -- the current mode. - if GUI.UpdateTabLayout then GUI:UpdateTabLayout() end - - -- If the active tab just became hidden (party-only while in raid), move to a - -- safe always-present tab so the user isn't left on a hidden/empty page. - if not GUI._redirectingTab and GUI.SelectedMode == "raid" and GUI.CurrentPageName then - local cur = GUI.Tabs[GUI.CurrentPageName] - if cur and cur.partyOnly and GUI.SelectTab then - GUI._redirectingTab = true - GUI.SelectTab("general_settings") - GUI._redirectingTab = false - end - end -end - --- Track currently open dropdown menu (only one can be open at a time) -S.currentOpenDropdown = nil - --- Close any currently open dropdown -local function CloseOpenDropdown() - if S.currentOpenDropdown and S.currentOpenDropdown:IsShown() then - S.currentOpenDropdown:Hide() - end - S.currentOpenDropdown = nil -end -P.CloseOpenDropdown = CloseOpenDropdown - --- Helper to get current theme color --- The theme colour for an EXPLICIT mode. Use this for any surface that belongs to --- a mode rather than to whatever page the options window happens to be showing -- --- movers, pinned containers -- so a raid surface stays orange while the window is --- on a party page. GetThemeColor() below is the follow-the-window variant. -local function GetThemeColorFor(isRaid) - if isRaid then return C_RAID else return C_ACCENT end -end -GUI.GetThemeColorFor = GetThemeColorFor - -local function GetThemeColor() - return GetThemeColorFor(GUI.SelectedMode == "raid") -end -GUI.GetThemeColor = GetThemeColor - --- Physical pixels per UI unit, measured from the frame's OWN effective scale. --- The GUI window carries a user scale (guiScale) on top of UIParent's and is --- freely resizable, so this is almost never 1 and cannot be read from the --- addon-wide DF:GetPixelScale (which is relative to UIParent). -local function PixelsPerUnit(frame) - local eff = frame and frame.GetEffectiveScale and frame:GetEffectiveScale() - local _, physH = GetPhysicalScreenSize() - if not (eff and eff > 0 and physH and physH > 0) then return nil end - return eff * physH / 768 -end -P.PixelsPerUnit = PixelsPerUnit - --- ⚠ THERE IS NO RUNTIME GEOMETRY CORRECTION, AND THERE SHOULD NOT BE ONE. --- --- This GUI used to carry a registry that nudged every bordered box onto the --- pixel grid after the layout had placed it. It is gone, and the reason is --- structural rather than a bug in the implementation: Lua-side snapping can only --- correct a frame at REST, and the symptom it was chasing -- a thin border going --- soft -- is at its worst while the content is MOVING, during a scroll. --- Correcting mid-scroll does not fix that, it adds a half-pixel jump on top of --- it, which reads as flicker. It also cost us the button-row drift, where an --- unordered sweep corrected chained anchors in a different order each pass. --- --- Three things replaced it, and between them they are enough: --- * the LAYOUT picks whole-pixel numbers in the first place (SnapLen below, at --- the point an offset, width or height is chosen). No runtime cost, nothing --- to drift, nothing to flicker -- it just picks better numbers. --- * the CLIP SURFACES are snapped (page viewport insets, the content panel, --- the nav chain). Those were real bugs: a box clipped by a fractional edge --- loses part of its border no matter how well the box itself is placed. --- * the BORDER is drawn as two device pixels of our own texture rather than a --- one-unit backdrop edge, so it cannot land badly in the first place. See --- PIXEL BORDER further down -- that is the part that actually solved it. - --- Round a LENGTH or OFFSET (in UI units) to a whole number of device pixels at --- the scale `frame` is drawn at. This is the layout-side half of the pixel-grid --- work, and the half that was missing. --- --- A box lands on the grid only if the numbers it was GIVEN were whole pixels. --- They usually are not: a group's inner width is (its width - 2 * padding), and --- its padding is 10 UI units, which is a whole number of device pixels only when --- the GUI is at exactly 1:1 scale. Everywhere else the box's right and bottom --- edges fall mid-pixel, and anything clipped by them loses part of itself. --- --- Snapping at the point the number is CHOSEN fixes that by construction, for --- everything a layout places, with no per-widget opt-in for anyone to forget. -local function SnapLen(frame, v) - if not v then return v end - local ppu = PixelsPerUnit(frame) - if not ppu then return v end - return math.floor(v * ppu + 0.5) / ppu -end -GUI.SnapLen = SnapLen - --- SnapLen rounds to the NEAREST device pixel, which can round a width DOWN and --- clip the text it was measured from. This rounds up instead: for anything whose --- length comes from a measurement (a button sized to its own label), the width --- has to be at least what was asked for, and on the grid. -local function SnapLenUp(frame, v) - if not v then return v end - local ppu = PixelsPerUnit(frame) - if not ppu then return v end - return math.ceil(v * ppu) / ppu -end -GUI.SnapLenUp = SnapLenUp - --- Round a HEIGHT to an EVEN number of device pixels (minimum two). --- --- Rows of controls are chained with centre-aligning anchors -- SetPoint("RIGHT", --- prev, "LEFT", gap, 0) aligns the two frames' vertical CENTRES, and the toolbar --- does the same with LEFT/RIGHT. A frame's centre is bottom + height/2, so if the --- height is an ODD number of device pixels the centre falls on a half pixel, and --- every frame chained off it inherits that half-pixel offset no matter how well --- its own edges are snapped. Even heights make the whole row land together. --- --- Used for control heights, which the factories set once at construction. Widths --- do not need this: nothing centre-anchors horizontally off a control. -local function SnapHeightEven(frame, v) - if not v then return v end - local ppu = PixelsPerUnit(frame) - if not ppu then return v end - return math.max(2, math.floor(v * ppu / 2 + 0.5) * 2) / ppu -end -GUI.SnapHeightEven = SnapHeightEven - --- ============================================================ --- Stamped once when this file loads, so it identifies THIS session (and therefore --- this build) for the pixelcheck and gapcheck captures. Declared HERE, above the --- first function that reads it: a local declared further down the file is not an --- upvalue for a function defined above it -- the read would silently resolve to a --- nil global and every capture would look like a new session. -local GAP_SESSION = date and date("%Y-%m-%d %H:%M:%S") or "?" - --- /df debug pixelcheck -- measure, don't guess --- --- The "top border of a box goes missing until you scroll" bug has now been --- diagnosed twice from reasoning about the layout and fixed twice, and it is --- still here. The two remaining explanations look IDENTICAL in a screenshot: --- --- (a) sub-pixel -- the box's top edge lands between two device rows, so the --- 1px line is filtered across both at half intensity; --- (b) clipping -- the box's top edge sits within a pixel of the ScrollFrame's --- own clip boundary, so the line is simply cut off. --- --- Scrolling "fixes" both, which is exactly why the screenshot can't separate --- them. This reports the numbers for the open page so the next change is aimed. --- Debug output: deliberately raw, like the other /df debug dumps. --- ============================================================ - --- Signed distance from `v` (UI units) to the nearest whole device pixel. -local function PixelOffsetOf(v, ppu) - if not v or not ppu then return nil end - local px = v * ppu - return px - math.floor(px + 0.5) -end - -local function DescribeFrame(f) - -- Prefer a human label so the output names the card, not "Frame". - for _, key in ipairs({ "label", "title", "titleText", "Text", "header" }) do - local o = f[key] - if type(o) == "table" and o.GetText then - local t = o:GetText() - if t and t ~= "" then return t end - end - end - if f.GetRegions then - for _, r in ipairs({ f:GetRegions() }) do - if r.GetObjectType and r:GetObjectType() == "FontString" then - local t = r:GetText() - if t and t ~= "" then return t end - end - end - end - return (f.GetObjectType and f:GetObjectType()) or "Frame" -end - --- Every SHOWN descendant carrying a border -- the only frames that can exhibit --- this bug (a fill-only surface has no edge to lose). --- --- Both mechanisms, deliberately. Testing edgeFile alone was right when that was --- the only way a border got drawn; now that most surfaces own their four --- textures instead, an edgeFile-only sweep would report a converted page as --- having no bordered frames at all and the probe would quietly stop being able --- to see the thing it exists to measure. -local function CollectBorderedFrames(root, out, depth) - if not root or depth > 10 then return out end - for _, child in ipairs({ root:GetChildren() }) do - if child:IsShown() then - local bd = child.GetBackdrop and child:GetBackdrop() - if (bd and bd.edgeFile) or child._pxBorder then out[#out + 1] = child end - CollectBorderedFrames(child, out, depth + 1) - end - end - return out -end - --- What sits ON the box's top border row. --- --- Geometry came back perfect on BOTH a broken and a working page (BOX 0/n, --- top+0.00, edge=1.00px, nothing near the clip edge), so the difference is not --- anything the box itself measures. The one thing that tracked the symptom was --- how far the first box sits from the top: 62.7px on a page that loses its --- border vs 76.8px on one that doesn't -- 14.1px apart, which at 1.4062 px/unit --- is exactly the 10-unit group padding. A box that starts hard against whatever --- is above it loses the line; one with a gap keeps it. That is the signature of --- the row above covering it, so: find any sibling whose rect spans the box's top --- border row, and report it with its frame level. -local function FindTopRowOverlaps(box) - local out = {} - local top, bL, bR = box:GetTop(), box:GetLeft(), box:GetRight() - local parent = box:GetParent() - if not (top and bL and bR and parent) then return out end - -- ~1.5 device px expressed in UI units: the border row plus a hair. - local band = 1.5 / (PixelsPerUnit(box) or 1) - for _, sib in ipairs({ parent:GetChildren() }) do - if sib ~= box and sib.IsShown and sib:IsShown() then - local sT, sB, sL, sR = sib:GetTop(), sib:GetBottom(), sib:GetLeft(), sib:GetRight() - if sT and sB and sL and sR - and sB <= top + band and sT >= top - band -- spans the border row - and sR > bL and sL < bR then -- and overlaps horizontally - out[#out + 1] = ("%s(lvl%d)"):format( - DescribeFrame(sib):sub(1, 16), sib:GetFrameLevel() or 0) - end - end - end - return out -end - -function GUI.PixelCheck() - local page, pageName - for name, p in pairs(GUI.Pages or {}) do - if p.IsShown and p:IsShown() then page, pageName = p, name; break end - end - if not page then - DF:Say("Pixel check", "no settings page is open", "WARN") - return - end - - local ppu = PixelsPerUnit(page) - if not ppu then - DF:Say("Pixel check", "scale unresolved — is the window shown?", "WARN") - return - end - - local scroll = page.GetVerticalScroll and page:GetVerticalScroll() or 0 - local scrollOff = PixelOffsetOf(scroll, ppu) - local clipTop = page:GetTop() - - local o = DF:Out("Pixel Check", ("page %s"):format(tostring(pageName))) - o:Section("Scale") - o:Field("effective scale", ("%.4f"):format(page:GetEffectiveScale() or 0), "NEUTRAL") - o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") - - o:Section("Scroll") - -- Reported, not judged. This used to print a red NOT SNAPPED when the offset - -- was off-grid, back when the scroll offset was quantised and being off-grid - -- meant something had gone wrong. Nothing quantises it now -- a 2px border - -- draws the same ink at any offset -- so an off-grid figure here is the - -- normal state of a scrolled page, and flagging it as a fault sends the next - -- person reading this output after a bug that is not there. - o:Field("offset", ("%.3f (%.2f px off grid)"):format(scroll, scrollOff or 0), "NEUTRAL") - o:Line("Off-grid here is expected — nothing quantises the scroll offset.", "NEUTRAL") - - -- THE CLIP BOUNDARY ITSELF. Earlier runs measured each box's DISTANCE to this - -- edge but never whether the edge is on-grid. A ScrollFrame clips to its own - -- rect, so if that rect's top sits on a fractional device row the cut takes a - -- partial row off whatever is nearest it -- which is exactly the reported - -- pattern: pages whose boxes start at the top lose the border, pages with a - -- gap do not. The scroll child is included because content is positioned - -- against it, so its phase is what every box inherits. - local dPageTop = PixelOffsetOf(page:GetTop(), ppu) - local dPageBot = PixelOffsetOf(page:GetBottom(), ppu) - local dPageH = PixelOffsetOf(page:GetHeight(), ppu) - -- Flag EITHER edge. This used to test only the top, so it printed - -- "bot-0.38" on every run for weeks and never once marked it -- and an - -- unflagged number in a wall of numbers is an invisible one. The bottom - -- edge clips whatever rests against it just as hard as the top does, which - -- is the entire See-Also footer bug. - local badTop = dPageTop and math.abs(dPageTop) > 0.05 - local badBot = dPageBot and math.abs(dPageBot) > 0.05 - o:Section("Viewport", "the clip edge") - o:Field("offsets", ("top%+.2f bot%+.2f h%+.2f"):format(dPageTop or 0, dPageBot or 0, dPageH or 0), - (badTop or badBot) and "BAD" or "GOOD") - if badTop or badBot then - o:Line(("Clip edge OFF-GRID (%s) — it shaves a partial row off whatever rests against it."):format( - badTop and (badBot and "top and bottom" or "top") or "bottom"), "BAD") - end - local kid = page.child or (page.GetScrollChild and page:GetScrollChild()) - if kid then - local kppu = PixelsPerUnit(kid) or ppu - local kidBad = math.abs(PixelOffsetOf(kid:GetTop(), kppu) or 0) > 0.05 - o:Field("scroll child", ("top%+.2f w%+.2f"):format( - PixelOffsetOf(kid:GetTop(), kppu) or 0, PixelOffsetOf(kid:GetWidth(), kppu) or 0), - kidBad and "BAD" or "GOOD") - if kidBad then - o:Line("Content is positioned against this, so every box inherits its phase.", "BAD") - end - end - - -- "Is it the section BOXES or the controls inside them?" is the question the - -- first version of this could not answer -- it ranked worst-first and the top - -- of the list was all controls, so the groups never showed. Classify, and - -- report the boxes separately no matter where they rank. - local function KindOf(f) - if f.LayoutChildren then return "BOX" end -- CreateSettingsGroup - if f.slider then return "slider" end - return (f.GetObjectType and f:GetObjectType()) or "frame" - end - - local frames = CollectBorderedFrames(page.child or page, {}, 0) - local rows, offGrid, nearClip = {}, 0, 0 - local byKind = {} - for _, f in ipairs(frames) do - local top, bottom, h = f:GetTop(), f:GetBottom(), f:GetHeight() - local fppu = PixelsPerUnit(f) or ppu - local dTop = PixelOffsetOf(top, fppu) - local dBot = PixelOffsetOf(bottom, fppu) - local dH = PixelOffsetOf(h, fppu) - -- Distance from the scroll viewport's top clip edge, in device pixels. - local clipGap = (top and clipTop) and ((clipTop - top) * fppu) or nil - if dTop and math.abs(dTop) > 0.05 then offGrid = offGrid + 1 end - if clipGap and clipGap > -1.5 and clipGap < 1.5 then nearClip = nearClip + 1 end - -- Edge THICKNESS, which is the leg two earlier revisions of this probe - -- did not capture and the one that turned out to matter. A box can - -- measure a perfect 0.00 on every edge and still lose its border if the - -- edge is drawn a fractional number of device pixels wide: it bleeds - -- into the next row at partial intensity, and a settings-group edge is - -- only ~8% alpha over a 3% fill, so both halves can land under the - -- visibility floor. Alpha is reported alongside because it sets that - -- floor. - -- - -- A pixel border reports its own thickness instead: it is authored in - -- device pixels, so it is a whole number by construction and edgeFrac - -- is 0 -- which is exactly the point of it, and worth being able to SEE - -- next to a surface still on the old edge. - local bdInfo = f.GetBackdrop and f:GetBackdrop() - local edgeUnits = bdInfo and bdInfo.edgeSize or nil - -- f._pxDevicePx, not a recomputation from PX_BORDER_THICKNESS: that - -- constant is declared several hundred lines below this probe, so - -- naming it here would resolve to a nil GLOBAL, silently. Reading what - -- LayoutPixelBorder actually drew is both safer and more truthful. - local edgePx = edgeUnits and (edgeUnits * fppu) or f._pxDevicePx or nil - local edgeFrac = edgePx and (edgePx - math.floor(edgePx + 0.5)) or nil - -- NOT `local _,_,_,a = (f:GetBackdropBorderColor())` -- the parentheses - -- truncate a multi-return to ONE value, so alpha came back nil every time - -- and every row printed "a=n/a". Alpha is the whole point here: a - -- settings-group edge is ~8% over a 3% fill, so it has almost no margin. - local borderA - if f.GetBackdropBorderColor then - local _, _, _, a = f:GetBackdropBorderColor() - borderA = a - end - local kind = KindOf(f) - local bad = (dTop and math.abs(dTop) > 0.05) or false - local k = byKind[kind] - if not k then k = { n = 0, bad = 0 }; byKind[kind] = k end - k.n = k.n + 1 - if bad then k.bad = k.bad + 1 end - rows[#rows + 1] = { - label = DescribeFrame(f), kind = kind, bad = bad, - frame = f, level = f.GetFrameLevel and f:GetFrameLevel() or nil, - dTop = dTop or 0, dBot = dBot or 0, - dH = dH or 0, clipGap = clipGap, - edgePx = edgePx, edgeFrac = edgeFrac, alpha = borderA, - score = math.max(math.abs(dTop or 0), - (clipGap and math.abs(clipGap) < 1.5) and 1 or 0), - } - end - - table.sort(rows, function(a, b) return a.score > b.score end) - o:Section("Bordered frames", #rows) - o:Field("off-grid top", offGrid, offGrid > 0 and "BAD" or "GOOD") - o:Field("within 1px of the clip edge", nearClip, nearClip > 0 and "WARN" or "GOOD") - - -- Per-kind tally: this is the line that says whether fixing controls would - -- also fix the section boxes, or whether they are a separate problem. - local kindLine = {} - for kind, k in pairs(byKind) do - kindLine[#kindLine + 1] = ("%s %d/%d"):format(kind, k.bad, k.n) - end - table.sort(kindLine) - o:Field("by kind (bad/total)", table.concat(kindLine, " "), "NEUTRAL") - - local function emit(r) - local flag = "" - if r.bad then flag = " |cffff6060OFF-GRID|r" end - if r.clipGap and math.abs(r.clipGap) < 1.5 then flag = flag .. " |cffffaa00AT-CLIP|r" end - -- A fractional edge width is the failure a perfect 0.00 box can still have. - if r.edgeFrac and math.abs(r.edgeFrac) > 0.05 then flag = flag .. " |cffff6060SOFT-EDGE|r" end - if r.alpha and r.alpha < 0.15 then flag = flag .. " |cffffaa00FAINT|r" end - print((" [%s] %-22s top%+.2f bot%+.2f h%+.2f edge=%s a=%s clip=%s%s"):format( - r.kind:sub(1, 6), r.label:sub(1, 22), r.dTop, r.dBot, r.dH, - r.edgePx and ("%.2fpx"):format(r.edgePx) or "n/a", - r.alpha and ("%.2f"):format(r.alpha) or "n/a", - r.clipGap and ("%.1f"):format(r.clipGap) or "n/a", flag)) - end - - -- The section boxes ALWAYS get listed, however they rank -- they are the ones - -- you can actually see, and ranking buried them last time. - local boxes = 0 - for _, r in ipairs(rows) do if r.kind == "BOX" then boxes = boxes + 1 end end - if boxes > 0 then - o:Section("Section boxes", boxes .. " — the outlines around each section") - local shown = 0 - for _, r in ipairs(rows) do - if r.kind == "BOX" and shown < 10 then - emit(r) - -- Anything sitting ON this box's top border row is the prime - -- suspect now that the box's own geometry measures clean. - local over = r.frame and FindTopRowOverlaps(r.frame) or {} - if #over > 0 then - print((" |cffff6060^ COVERED BY:|r %s (box is lvl%s)") - :format(table.concat(over, ", "), tostring(r.level))) - end - shown = shown + 1 - end - end - else - o:Section("Section boxes") - o:Line("None found on this page.", "WARN") - end - - o:Section("Worst overall") - o:Line("topOff/botOff/heightOff are px from the grid; clip is px below the viewport top.", "NEUTRAL") - for i = 1, math.min(#rows, 10) do emit(rows[i]) end - print(" |cff808080Read: OFF-GRID = geometry. SOFT-EDGE = the edge is a fractional number of device px wide, so it bleeds into the next row -- a box can be a perfect 0.00 and still lose its border this way. FAINT = so little alpha that any split is invisible.|r") - print(" |cff808080Nothing is corrected at runtime any more. Every widget gets its whole-pixel numbers from its FACTORY at construction (nudging them afterwards is what made chained button rows drift), so an off-grid row after a scale change is expected and is not a bug. What still matters here is SOFT-EDGE and FAINT.|r") - - -- Persist, for the same reason gapcheck does: transcribing a screenful of - -- numbers out of the chat frame is not practical remotely, and every reading - -- of this symptom that came from eyeballing rather than the file has been - -- wrong. Same session stamp, so a capture never mixes two builds. - DandersFramesDebugDB = DandersFramesDebugDB or {} - if DandersFramesDebugDB.pixelSession ~= GAP_SESSION then - DandersFramesDebugDB.pixelcheck = nil - DandersFramesDebugDB.pixelSession = GAP_SESSION - end - DandersFramesDebugDB.pixelcheck = DandersFramesDebugDB.pixelcheck or {} - local pdump = { - when = date("%Y-%m-%d %H:%M:%S"), - ppu = ppu, scroll = scroll, - viewTop = dPageTop, viewBot = dPageBot, viewH = dPageH, - rows = {}, - } - for i, r in ipairs(rows) do - pdump.rows[i] = { - label = tostring(r.label):sub(1, 40), kind = r.kind, - dTop = r.dTop, dBot = r.dBot, dH = r.dH, - edgePx = r.edgePx, alpha = r.alpha, clipGap = r.clipGap, - level = r.level, - -- Raw geometry too: the deltas alone cannot distinguish "off-grid" - -- from "the right size but drawn somewhere unexpected". - top = r.frame and r.frame:GetTop() or nil, - bottom = r.frame and r.frame:GetBottom() or nil, - height = r.frame and r.frame:GetHeight() or nil, - width = r.frame and r.frame:GetWidth() or nil, - points = r.frame and r.frame:GetNumPoints() or nil, - } - end - -- Never overwrite an earlier run of the SAME page: the whole point of running - -- this twice is to compare a broken state against a working one, and keying - -- purely by page silently threw the first away. Numbered within the session. - local key, n = tostring(pageName), 1 - while DandersFramesDebugDB.pixelcheck[key] do - n = n + 1 - key = ("%s #%d"):format(tostring(pageName), n) - end - DandersFramesDebugDB.pixelcheck[key] = pdump - print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.pixelcheck[\"%s\"] -- |cffffcc00/reload to flush.|r") - :format(#rows, key)) -end - --- ============================================================ --- /df debug navprobe -- catch the left-nav hover flash in the act --- --- The symptom: sweeping the cursor down the nav list shows a "ghost" -- of the --- row's text, or of the bottom part of the hover plate. It happens at moderate --- speed, not just fast, and it survived snapping the row geometry. --- --- Four causes would produce that, and they are INDISTINGUISHABLE in a --- screenshot, which is why this measures instead of reasoning: --- --- (a) two rows lit at once -- the previous row's OnLeave never ran, so two --- plates are visible together for a frame or two; --- (b) focus thrash -- the cursor sits still over one row but mouse focus --- alternates between it and something else (the scroll frame, a sibling, --- an overlapping rect), so the plate flickers on and off in place; --- (c) geometry -- rows overlap, or leave a dead band between them where --- NOTHING is lit, so crossing it reads as the plate breaking up; --- (d) none of the above -- a pure rendering artefact, in which case the trace --- shows exactly one clean enter/leave per row and the answer is elsewhere. --- --- Static geometry first (overlaps and dead bands are visible without moving the --- mouse), then a live trace of every change in focus and in each row's plate --- alpha, stamped with the frame number. (a) and (b) show up as repeated --- transitions within a single crossing; (c) as a run of frames with nothing lit. -function GUI.NavProbe(seconds) - local container = GUI.tabContainer - if not (container and container:IsVisible()) then - DF:Say("the settings window is not open.") - return - end - local ppu = PixelsPerUnit(container) or 1 - - -- Rows in LAYOUT order (top to bottom), which is what makes the neighbour - -- comparison below meaningful -- GetChildren order is creation order. - local rows = {} - for _, catName in ipairs(GUI.CategoryOrder or {}) do - local cat = GUI.Categories and GUI.Categories[catName] - if cat and cat:IsShown() then - rows[#rows + 1] = { f = cat, label = "[" .. tostring(catName) .. "]" } - for _, btn in ipairs(cat.children or {}) do - if btn:IsShown() then - rows[#rows + 1] = { f = btn, label = DescribeFrame(btn) } - end - end - end - end - - local o = DF:Out("Nav Probe") - o:Section("Rows") - o:Field("visible", #rows, #rows > 0 and "GOOD" or "WARN") - o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") - - -- The ANCESTOR CHAIN, because a row cannot be on the grid if the frame it - -- hangs off is not: every ancestor here is two-corner anchored, so nothing - -- corrects them after the fact and a fraction anywhere propagates to all 42 - -- rows identically. If the rows read a uniform offset, this line says which - -- link introduced it -- that is how the 4-unit nav inset (0.375px) was found - -- after the rows themselves measured clean on height. - local chain, node = {}, container - while node and #chain < 6 do - local t = node:GetTop() - chain[#chain + 1] = ("%s top%+.2f"):format( - (node.GetObjectType and node:GetObjectType() or "?"):sub(1, 6), - PixelOffsetOf(t, PixelsPerUnit(node) or ppu) or 0) - node = node:GetParent() - end - print(" chain (row -> window): " .. table.concat(chain, " | ")) - - -- Geometry: the gap to the row above, in DEVICE pixels. Negative = the rows - -- overlap (both can claim the cursor); more than ~1px positive = a dead band - -- with no row under the cursor at all. Either one produces a visible break. - local overlaps, bands = 0, 0 - for i, r in ipairs(rows) do - local f = r.f - local top, bot, h = f:GetTop(), f:GetBottom(), f:GetHeight() - local dTop = PixelOffsetOf(top, ppu) or 0 - local dH = PixelOffsetOf(h, ppu) or 0 - local gap - if i > 1 then - local prevBot = rows[i - 1].f:GetBottom() - if prevBot and top then gap = (prevBot - top) * ppu end - end - local flag = "" - if gap and gap < -0.05 then flag = " |cffff6060OVERLAPS ABOVE|r"; overlaps = overlaps + 1 - elseif gap and gap > 1.05 then flag = " |cffffaa00DEAD BAND|r"; bands = bands + 1 end - if math.abs(dTop) > 0.05 or math.abs(dH) > 0.05 then - flag = flag .. " |cffffaa00OFF-GRID|r" - end - print((" %-24s top%+.2f h%+.2f gap=%s lvl%d%s"):format( - r.label:sub(1, 24), dTop, dH, - gap and ("%.2fpx"):format(gap) or "n/a", - f:GetFrameLevel() or 0, flag)) - end - print((" %d overlapping rows, %d dead bands between rows"):format(overlaps, bands)) - - -- Live trace. - S.navTrace = S.navTrace or CreateFrame("Frame") - S.navTrace:SetScript("OnUpdate", nil) - local dur = tonumber(seconds) or 8 - local elapsed, frames, events = 0, 0, 0 - local lastLit, lastFocus = nil, nil - print((" |cff00ff00tracing for %ds|r -- sweep the cursor across the nav list now."):format(dur)) - - S.navTrace:SetScript("OnUpdate", function(_, dt) - elapsed = elapsed + dt - frames = frames + 1 - - -- Which row the shared plate is parked on. There is only one plate now, so - -- "TWO LIT" is structurally impossible -- that is the point of the change, - -- and this still checks for it in case the plate is ever reintroduced - -- per-row. No parentheses around the colour call: they would truncate the - -- multi-return to one value and alpha would read nil every frame -- the - -- exact mistake that cost three rounds on the border bug. - local lit = {} - local hl = GUI.navHover - if hl and hl:IsShown() and hl.owner then - local _, _, _, a = hl:GetBackdropColor() - if a and a > 0.01 then - for _, r in ipairs(rows) do - if r.f == hl.owner then lit[#lit + 1] = r.label:sub(1, 18) break end - end - end - end - local litKey = table.concat(lit, "+") - - -- What actually owns the mouse. If this is NOT the lit row, the plate and - -- the focus disagree, which is cause (b). - local focus = "-" - local foci = GetMouseFoci and GetMouseFoci() - local top = (foci and foci[1]) or (GetMouseFocus and GetMouseFocus()) - if top then - for _, r in ipairs(rows) do if r.f == top then focus = r.label:sub(1, 18) break end end - if focus == "-" then - focus = "<" .. ((top.GetObjectType and top:GetObjectType()) or "?") .. ">" - end - end - - if litKey ~= lastLit or focus ~= lastFocus then - events = events + 1 - if events <= 120 then - print((" f%-5d t=%.3f lit=%-24s focus=%s%s"):format( - frames, elapsed, - (litKey ~= "" and litKey or "(none)"), - focus, - (#lit > 1) and " |cffff6060TWO LIT|r" - or ((litKey == "" and focus ~= "-") and " |cffffaa00FOCUS BUT UNLIT|r" or ""))) - end - lastLit, lastFocus = litKey, focus - end - - if elapsed >= dur then - S.navTrace:SetScript("OnUpdate", nil) - print((" |cff00ff00navprobe done|r -- %d frames, %d state changes%s"):format( - frames, events, events > 120 and " (first 120 shown)" or "")) - print(" |cff808080Read: one enter + one leave per row = clean, look elsewhere. Repeated flips inside one crossing = focus thrash. TWO LIT = a stale plate. lit=(none) with focus on a row = the handler did not fire.|r") - end - end) -end - --- ============================================================ --- /df debug gapcheck -- measure the vertical rhythm, don't eyeball it --- --- The question this answers: "which rows are too far apart, and which are too --- close?" It cannot be answered from GUI.RowHeight alone, because those numbers --- are SLOT heights, not gaps. LayoutChildren stacks rows flush (y = y - height), --- so the visible gap between two rows is: --- --- (slot height - content bottom) of the row above --- + (slot top - content top) of the row below --- --- A row whose content is short inside a tall slot gets a big gap and nothing --- flags it. The slider is the worst case by construction: it shares the 55 slot --- with the dropdown and edit box, whose openers reach ~40, while a slider only --- draws to ~32 (label, an 8px track, a 20px value box) -- so it carries ~23px of --- slack against their ~15. --- --- Measured, not derived, for a reason: labels WRAP, override markers hang off --- rows, banners re-measure themselves after construction, and hideOn rows drop --- out. Only the live rects know the real content extent, which is exactly the --- lesson from the border bug -- the arithmetic looked right there too. --- --- Rows are compared only against their SIBLINGS IN THE SAME GROUP. That is where --- the rhythm actually reads, and it sidesteps having to reconstruct which column --- a widget landed in. -local function ContentExtent(f) - local top, bottom - local function acc(o) - if not o or not o.IsShown or not o:IsShown() then return end - -- Skip things that draw NOTHING: an empty label or a fully transparent - -- placeholder still has a rect, and counting it would inflate the content - -- and hide the very slack we are looking for. - -- - -- FontStrings ONLY. An EditBox also answers GetText, and an empty one - -- would have been skipped here even though its box is plainly drawn -- - -- which would have under-measured every blank input on the page. - if o.GetObjectType and o:GetObjectType() == "FontString" then - local s = o:GetText() - if s == nil or s == "" then return end - end - if o.GetAlpha and (o:GetAlpha() or 1) <= 0.01 then return end - local t, b = o:GetTop(), o:GetBottom() - if t and b then - top = (top and math.max(top, t)) or t - bottom = (bottom and math.min(bottom, b)) or b - end - end - if f.GetRegions then for _, r in ipairs({ f:GetRegions() }) do acc(r) end end - if f.GetChildren then for _, c in ipairs({ f:GetChildren() }) do acc(c) end end - return top, bottom -end - --- Every SHOWN SettingsGroup under the page, at any depth (the Aura Designer nests --- its groups inside cards). -local function CollectGroups(root, out, depth) - if not root or (depth or 0) > 8 then return out end - if root.groupChildren and root.IsShown and root:IsShown() then out[#out + 1] = root end - if root.GetChildren then - for _, c in ipairs({ root:GetChildren() }) do - if c.IsShown and c:IsShown() then CollectGroups(c, out, (depth or 0) + 1) end - end - end - return out -end - -function GUI.GapCheck(mode) - if mode == "clear" then - DandersFramesDebugDB = DandersFramesDebugDB or {} - DandersFramesDebugDB.gapcheck = nil - DF:Say("saved capture cleared (/reload to flush).") - return - end - - local page, pageName - for name, p in pairs(GUI.Pages or {}) do - if p.IsShown and p:IsShown() then page, pageName = p, name break end - end - if not page then - DF:Say("Gap check", "no settings page is open", "WARN") - return - end - - local groups = CollectGroups(page.child or page, {}, 0) - local rows, byKind, pairs_, nRows = {}, {}, {}, 0 - - for _, group in ipairs(groups) do - local prev - for _, entry in ipairs(group.groupChildren or {}) do - local w = entry.widget - if w and w.IsShown and w:IsShown() and entry.height then - local slotTop = w:GetTop() - local cTop, cBot = ContentExtent(w) - if slotTop and cTop and cBot then - -- The slot is entry.height from the widget's TOP -- NOT the - -- widget's own rect. LayoutChildren only sets TOPLEFT (and - -- width), so a container constructed at 50 sitting in a 55 - -- slot would under-report by 5 if we used GetBottom(). - local slotBot = slotTop - entry.height - local kind = w.rowKind or (w.LayoutChildren and "group") - or (w.GetObjectType and w:GetObjectType()) or "?" - local r = { - kind = kind, - label = (w.GetText and w:GetText()) or (w.Text and w.Text.GetText and w.Text:GetText()) or kind, - slot = entry.height, - content = cTop - cBot, - padTop = slotTop - cTop, - padBot = cBot - slotBot, - -- Absolute edges, because the gap has to be measured from - -- where the rows LANDED, not from entry.height. The layout - -- can shorten a row after the fact (the compact-run - -- tightening does exactly that), and slot arithmetic - -- cannot see it -- the first version of this reported the - -- untightened number and made the feature look inert. - cTop = cTop, - cBot = cBot, - -- The slot's own top, so the height the layout ACTUALLY - -- used is derivable offline (prev.slotTop - this.slotTop) - -- and can be compared against entry.height. - slotTop = slotTop, - tight = w._rowTightened or false, - nextKind = w._rowNextKind, - } - nRows = nRows + 1 - rows[#rows + 1] = r - - local k = byKind[kind] - if not k then k = { n = 0, slot = 0, content = 0, padTop = 0, padBot = 0 } byKind[kind] = k end - k.n, k.slot, k.content = k.n + 1, k.slot + r.slot, k.content + r.content - k.padTop, k.padBot = k.padTop + r.padTop, k.padBot + r.padBot - - -- The gap the eye actually sees: the distance between where - -- the previous row's content ENDED and this one's STARTS, - -- straight off the resolved rects. - if prev then - local gap = prev.cBot - r.cTop - local key = ("%s -> %s"):format(prev.kind, kind) - local p = pairs_[key] - if not p then p = { n = 0, sum = 0, min = gap, max = gap } pairs_[key] = p end - p.n, p.sum = p.n + 1, p.sum + gap - p.min, p.max = math.min(p.min, gap), math.max(p.max, gap) - end - prev = r - end - end - end - end - - if nRows == 0 then - local o = DF:Out("Gap Check", "page " .. tostring(pageName)) - o:Line("No measurable rows — is everything collapsed?", "WARN") - o:Siblings("gapcheck") - return - end - - local o = DF:Out("Gap Check", "page " .. tostring(pageName)) - o:Section("Measured", "UI units") - o:Field("groups", #groups, "NEUTRAL") - o:Field("rows", nRows, "NEUTRAL") - - o:Section("Per kind") - o:Line("slot is what RowHeight hands out; content is what it actually draws.", "NEUTRAL") - local kinds = {} - for kind in pairs(byKind) do kinds[#kinds + 1] = kind end - table.sort(kinds, function(a, b) return (byKind[a].padBot / byKind[a].n) > (byKind[b].padBot / byKind[b].n) end) - for _, kind in ipairs(kinds) do - local k = byKind[kind] - print((" %-12s n=%-3d slot %5.1f content %5.1f padTop %4.1f |cffffcc00padBottom %4.1f|r") - :format(kind, k.n, k.slot / k.n, k.content / k.n, k.padTop / k.n, k.padBot / k.n)) - end - - -- Did the compact-run tightening actually fire? The gap alone cannot say -- - -- it only shows the result -- and reading the source said it should while the - -- measurement said it had not. So count the decision directly, and when a run - -- did NOT close up, name the kind that broke it. - local tightened, compactRows, breakers = 0, 0, {} - for _, r in ipairs(rows) do - if GUI.RowCompact[r.kind] then - compactRows = compactRows + 1 - if r.tight then - tightened = tightened + 1 - elseif r.nextKind then - breakers[r.nextKind] = (breakers[r.nextKind] or 0) + 1 - end - end - end - if compactRows > 0 then - local why = {} - for k, n in pairs(breakers) do why[#why + 1] = ("%s x%d"):format(k, n) end - table.sort(why) - print((" compact-run tightening: |cffffcc00%d/%d|r compact rows closed up%s") - :format(tightened, compactRows, - #why > 0 and (" |cff808080(run broken by: %s)|r"):format(table.concat(why, ", ")) or "")) - end - - print(" gaps between stacked rows (padBottom above + padTop below) -- widest first:") - local keys = {} - for key in pairs(pairs_) do keys[#keys + 1] = key end - table.sort(keys, function(a, b) return (pairs_[a].sum / pairs_[a].n) > (pairs_[b].sum / pairs_[b].n) end) - for _, key in ipairs(keys) do - local p = pairs_[key] - local avg = p.sum / p.n - -- A pair whose min and max differ is NOT a spacing constant -- something - -- (a wrapped label, a hand-rolled AddSpace) is varying it, and averaging - -- would hide exactly that. - local spread = (p.max - p.min > 0.5) - and (" |cffff6060varies %.1f..%.1f|r"):format(p.min, p.max) or "" - print((" %6.1f %-26s x%d%s"):format(avg, key, p.n, spread)) - end - - if mode == "all" then - print(" every row, in layout order:") - for _, r in ipairs(rows) do - print((" %-12s %-22s slot %5.1f content %5.1f padTop %4.1f padBottom %4.1f") - :format(r.kind, tostring(r.label):sub(1, 22), r.slot, r.content, r.padTop, r.padBot)) - end - end - - -- Persist the RAW rows to SavedVariables. Reading a hundred rows out of the - -- chat frame is not practical, and transcribing them by hand would introduce - -- exactly the kind of error this probe exists to avoid. - -- - -- Keyed BY PAGE so visiting several pages accumulates one dataset instead of - -- each run overwriting the last -- walk the pages you care about, then - -- /reload ONCE. SavedVariables are only flushed on logout or reload, so a run - -- that is never followed by one is never written. - -- - -- Its own saved variable, not a corner of DandersFramesDB_v2: diagnostics - -- must not sit inside the profile DB, where they would ride along with every - -- export and show up in the export audit. - DandersFramesDebugDB = DandersFramesDebugDB or {} - DandersFramesDebugDB.gapcheck = DandersFramesDebugDB.gapcheck or {} - -- Drop everything from a PREVIOUS session first. Accumulating by page is what - -- makes a multi-page capture possible, but it also means a page you did not - -- revisit after a code change keeps its stale rows -- and mixing two builds - -- invents variance that is not in either of them. (It already cost one wrong - -- diagnosis: three stale pages made the row heights look like they had not - -- applied at all.) One /reload = one dataset. - if DandersFramesDebugDB.gapSession ~= GAP_SESSION then - wipe(DandersFramesDebugDB.gapcheck) - DandersFramesDebugDB.gapSession = GAP_SESSION - end - local dump = { when = date("%Y-%m-%d %H:%M:%S"), rows = {} } - if page.GetEffectiveScale then dump.scale = page:GetEffectiveScale() end - for i, r in ipairs(rows) do - dump.rows[i] = { - kind = r.kind, label = tostring(r.label):sub(1, 40), - slot = r.slot, content = r.content, - padTop = r.padTop, padBot = r.padBot, - cTop = r.cTop, cBot = r.cBot, -- so gaps can be re-derived offline - slotTop = r.slotTop, tight = r.tight, nextKind = r.nextKind, - } - end - DandersFramesDebugDB.gapcheck[tostring(pageName)] = dump - - local nPages = 0 - for _ in pairs(DandersFramesDebugDB.gapcheck) do nPages = nPages + 1 end - print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.gapcheck[\"%s\"] -- %d page(s) captured. |cffffcc00Visit the pages you care about, then /reload to flush.|r") - :format(#rows, tostring(pageName), nPages)) - print(" |cff808080Read: padBottom is the slack under a row -- the knob is GUI.RowHeight[kind], and slot - content IS that slack. A kind sorting to the top of the first list is over-spaced; one near zero is cramped. In the second list, 'varies' means the gap is not coming from RowHeight alone. Add 'all' for every row, 'clear' to wipe the saved capture.|r") -end --- (Removed) GUI.GapCheckAll — a no-arg alias for GapCheck("all"), superseded once the --- dispatcher started forwarding the mode argument. Zero callers. - --- Every frame that carries a 1px backdrop edge, so the sweep below can find them --- without any page needing to know what it contains. Weak keys: a retired page's --- widgets are reparented to the trash frame rather than destroyed, and this must --- not be what keeps them reachable. - --- Re-derive the BORDER THICKNESS of everything currently on screen. Since the --- geometry correction was removed this no longer moves or resizes anything, so --- the only thing it can change is how many device pixels an edge is drawn at -- --- which only matters when the SCALE changes. That is why its callers are the --- scale slider and the window drag/resize handlers, not anything per-frame. --- --- IsVisible (not IsShown) keeps it cheap: a widget on a page that is not open --- has a hidden ancestor and is skipped, so the work stays proportional to what --- is displayed rather than to everything the registry has accumulated. Safe to --- call repeatedly -- it writes only when the computed edge width actually --- differs, so a second call in the same state does nothing at all. - --- ============================================================ --- PIXEL BORDER -- how every outlined GUI surface draws its edge. --- --- Four textures we own, instead of a backdrop's edgeFile, because a backdrop --- edge is drawn one UI unit wide and we cannot control how that lands on the --- physical pixel grid. At the scales people play at, one unit is about one --- device pixel, and a one-device-pixel line has exactly two states: [1.0, 0] --- when it lands on the grid and [0.5, 0.5] when it does not. Identical ink, --- completely different appearance -- so an edge that crosses the grid while the --- page scrolls appears to flicker, and at the low alphas this GUI uses, both --- halves of the split can fall under the visibility floor and the border simply --- is not there. --- --- The long way round to this was trying to place that 1px line perfectly. It --- cannot be done. Lua-side snapping runs at discrete moments -- build, --- scroll-settle, show -- but the line crosses the grid on EVERY frame of a --- scroll: correcting at moment N does nothing for the next thirty frames, and --- correcting mid-motion adds a visible jump on top of the softness. Handing it --- to the renderer (SetSnapToPixelGrid) is worse still: it rounds the two edges --- of a thin texture independently, so a 1px line can round to zero height and --- vanish outright. --- --- The answer is to stop placing a thin line accurately and draw one that does --- not care where it lands. See PX_BORDER_THICKNESS below. -local pixelBordered = setmetatable({}, { __mode = "k" }) -local PX_SIDES = { "top", "bottom", "left", "right" } - --- THE dial. Border thickness in DEVICE PIXELS, before any per-surface weight. --- --- The floor is what matters, not the exact value: anything above 1 always covers --- at least one FULL pixel row plus a partial, so the line can never vanish and --- never changes its total ink as the content moves. Only 1.0 has the two-state --- failure ([1.0, 0] when it lands on the grid, [0.5, 0.5] when it does not -- --- identical ink, completely different appearance), which is the flicker that --- started this. --- --- Back to 2, and the alpha carries the weight instead. THICKNESS and WEIGHT are --- separate dials and conflating them was the mistake: --- --- * thickness decides how UNIFORM the line looks. At 1.5 the rows come out --- [0.25, 1.0, 0.25] or [0.5, 1.0] depending on where the box lands, so --- neighbouring boxes render visibly different widths -- Krathe's "one --- thinner, one thicker". The bigger the base, the smaller that proportion, --- so 2 is noticeably more even than 1.5. --- * alpha decides how HEAVY it looks, and does not vary with position at all. --- --- So: hold thickness at the value that renders evenly, and take the weight out --- of the alpha. 2px at 0.7 alpha is about the same total ink as 1.5px at full, --- but without the width wobble. --- --- The floor still matters if this is ever tuned down: anything above 1 always --- covers a full row plus a partial, so it cannot vanish. Only 1.0 has the --- two-state failure -- [1.0, 0] on the grid, [0.5, 0.5] off it, identical ink --- and completely different appearance -- which was the original flicker. -local PX_BORDER_THICKNESS = 2 - --- Applied to every pixel border's alpha. The authored colours were all chosen --- for a 1px hairline; drawing them at 2px would read heavier than intended, and --- this puts that correction in ONE place rather than re-tuning six call sites --- (and whatever opts in later) by hand. -local PX_BORDER_ALPHA = 0.7 - --- Thickness and anchors, re-derived whenever the scale changes. --- frame._pxWeight is the caller's edgeSize (1 = the standard hairline), so a --- surface that asked for a heavier outline keeps its relative weight. -local function LayoutPixelBorder(frame) - local b = frame._pxBorder - if not b then return end - local ppu = PixelsPerUnit(frame) - if not ppu then return end - -- TWO device pixels, not one, and this is the whole finding. - -- - -- A 1px line cannot be drawn reliably at a fractional offset. With vertex - -- snapping ON, the top and bottom of a 1px-tall texture can round to the SAME - -- pixel row -- height zero, line GONE. That is what the red diagnostic showed: - -- verticals solid (X is stable), horizontals absent at certain scroll - -- positions (Y carries the scroll's fractional phase). The old backdrop edge - -- did it too, so this is not specific to either mechanism. - -- - -- With snapping OFF at 2px the rows come out [partial, full, partial] -- - -- roughly 0.5 / 1.0 / 0.5 -- for ANY offset. The total ink is constant and - -- there is always at least one fully-lit row, so the line can neither vanish - -- nor visibly change weight as the page scrolls. A 1px line has only the two - -- states [1.0, 0] and [0.5, 0.5]: same ink, completely different appearance, - -- which IS the flicker. - -- - -- So: stop trying to place a 1px line perfectly, and draw one that does not - -- care where it lands. - local devicePx = PX_BORDER_THICKNESS * (frame._pxWeight or 1) - local px = devicePx / ppu - frame._pxDevicePx = devicePx -- what /df debug pixelcheck reports for this surface - frame._pxPpu = ppu -- the scale this thickness was derived at - - b.top:ClearAllPoints() - b.top:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - b.top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) - b.top:SetHeight(px) - - b.bottom:ClearAllPoints() - b.bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) - b.bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - b.bottom:SetHeight(px) - - b.left:ClearAllPoints() - b.left:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - b.left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) - b.left:SetWidth(px) - - b.right:ClearAllPoints() - b.right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) - b.right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - b.right:SetWidth(px) -end - --- THE SHIM, and the reason buttons could not adopt this until now. --- --- A pixel border has no backdrop edgeFile, so SetBackdropBorderColor -- which is --- how ~150 call sites across the addon drive hover, active and disabled states --- -- would silently do nothing. Converting those by hand meant finding all of --- them, and missing one meant a button whose hover just stops working, with --- nothing to see in a log. --- --- So the frame gets its own method that shadows the mixin's. Every existing --- caller keeps working unchanged, and there is no list to be exhaustive about. --- Assigning onto the frame table is enough: SetBackdropBorderColor is not a --- native widget method, it arrives via BackdropTemplateMixin, so it is already --- a plain table entry and ours simply takes its place. -local function SetPixelBorderColor(self, r, g, b, a) - local bd = self._pxBorder - if not bd then return end - a = a or 1 - -- Mutated, not replaced. This is the hover path for every button, dropdown - -- and swatch in the GUI -- OnEnter and OnLeave both land here -- so a fresh - -- table per call would be pure garbage. - local c = self._pxColor - if c then c[1], c[2], c[3], c[4] = r, g, b, a - else self._pxColor = { r, g, b, a } end - -- Alpha scaled once, here, so the authored colours stay the values a reader - -- would expect to see (8%, 50%, 80%) rather than pre-compensated numbers. - local drawn = a * PX_BORDER_ALPHA - for _, side in ipairs(PX_SIDES) do - bd[side]:SetColorTexture(r, g, b, drawn) - end -end - --- Returns the AUTHORED colour, not the drawn one. PX_BORDER_ALPHA is a --- rendering correction, so a caller that reads a colour back and writes it again --- must not end up with it applied twice. -local function GetPixelBorderColor(self) - local c = self._pxColor - if not c then return end - return c[1], c[2], c[3], c[4] -end - --- Re-derive on show, so a surface built at one UI scale and opened after a scale --- change comes up at the right thickness. Self-registering on purpose: this --- used to be an explicit call that six floating windows each had to remember to --- make, and a bordered frame maintaining its own border cannot be forgotten. --- --- Guarded on the scale it was last laid out at, because this fires for every --- widget on every page open. In the ordinary case -- nothing has changed since --- the widget was built -- it costs one float compare and returns. -local function RelayoutPixelBorderOnShow(self) - if PixelsPerUnit(self) ~= self._pxPpu then LayoutPixelBorder(self) end -end - --- color = {r, g, b, a}; weight mirrors backdrop edgeSize (1 = standard hairline) -function GUI:ApplyPixelBorder(frame, color, weight) - frame._pxWeight = weight or frame._pxWeight or 1 - local b = frame._pxBorder - if not b then - b = {} - for _, side in ipairs(PX_SIDES) do - -- ARTWORK at the top sublevel, not BORDER. The colour picker is - -- what forces it up: its hue square lays a full-size ARTWORK - -- gradient over the frame, which would cover a BORDER-layer edge - -- completely -- and that is a trap, because the border would - -- disappear for a reason nothing about the border explains. - -- - -- Not OVERLAY, though. Every label, icon, check mark, grip and - -- slider thumb in this GUI is OVERLAY, and those belong above the - -- edge they sit inside. Sublevel 7 of ARTWORK clears the interior - -- fills and gradients and nothing else. - local tex = frame:CreateTexture(nil, "ARTWORK", nil, 7) - -- Snapping OFF, deliberately, and this is a reversal of what this - -- started out as. Vertex snapping is what COLLAPSES a thin line: - -- rounding a 2px texture's edges independently gives a height of 1, - -- 2 or 3 px depending on where it lands, so the border would - -- visibly change weight as you scroll. Left un-snapped it is always - -- exactly 2px of ink, wherever it falls -- constant, which is what - -- the eye actually wants. Crispness was never the goal; STABILITY - -- was. - if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end - -- No half-texel offset, so the 2px spans the rows we asked for. - if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end - b[side] = tex - end - frame._pxBorder = b - pixelBordered[frame] = true - if frame.HookScript then - frame:HookScript("OnShow", RelayoutPixelBorderOnShow) - end - end - -- Outside the create block on purpose: the textures are kept when a surface - -- is re-issued, so keying the shim off their creation would leave a - -- re-adopted frame with the real methods still in place and its recolours - -- going nowhere. Assigning every time is idempotent and cannot get this - -- wrong. - frame.SetPixelBorderColor = SetPixelBorderColor - frame.SetBackdropBorderColor = SetPixelBorderColor - frame.GetBackdropBorderColor = GetPixelBorderColor - local c = color or { 1, 1, 1, 0.08 } - SetPixelBorderColor(frame, c[1], c[2], c[3], c[4] or 1) - -- Re-shown because a surface can be re-issued as fill-only and back again - -- (FlashWidget does this on every pulse). - for _, side in ipairs(PX_SIDES) do b[side]:Show() end - LayoutPixelBorder(frame) - return frame -end - --- Hand the frame its own methods back, and take the textures down. Needed by the --- one path that leaves the pixel border behind: a surface re-issued with --- opts.backdropEdge after it had one. Nothing does that today, but if the shim --- were left installed the backdrop recolour that follows would go nowhere at --- all -- a hover that silently stops working, which is precisely the failure --- this system exists to make impossible. -local function RevertPixelBorder(frame) - if not frame or not frame._pxBorder then return end - GUI:HidePixelBorder(frame) - frame.SetPixelBorderColor = nil - frame.SetBackdropBorderColor = BackdropTemplateMixin.SetBackdropBorderColor - frame.GetBackdropBorderColor = BackdropTemplateMixin.GetBackdropBorderColor -end - --- Take the border down without discarding it. The textures are ours, so unlike a --- backdrop edge nothing else will remove them when the surface is re-issued --- without an outline. -function GUI:HidePixelBorder(frame) - local b = frame and frame._pxBorder - if not b then return end - for _, side in ipairs(PX_SIDES) do b[side]:Hide() end -end - --- Re-derive thickness on a scale change. Rides the same sweep that already --- re-derives backdrop edge widths, so there is no new per-frame work. -function GUI:RefreshPixelBorders() - for frame in pairs(pixelBordered) do - if frame:IsVisible() then LayoutPixelBorder(frame) end - end -end - --- THE element backdrop. Every bordered/filled GUI surface goes through here, so --- a change to the look lands everywhere at once. Three shapes, one code path: --- --- (default) fill + 1px outline -- dropdowns, edit boxes, buttons --- opts.fill=false outline only -- for a surface whose interior is drawn by --- something else (the colour picker's hue/alpha gradients --- and checkerboards), where a fill would paint over it --- opts.outline=false fill only -- flat chips, segment buttons, label plates --- --- opts.bgColor / opts.borderColor take {r,g,b[,a]} or {[1],[2],[3][,4]} and --- override the C_ELEMENT / C_BORDER defaults. opts.edgeSize thickens the outline --- (the popup and wizard chrome use 2). opts.inset (a --- single number, applied to all four sides) pulls the fill in from the edge so it --- does not underlap a translucent border -- default 0, i.e. the fill runs to the --- frame edge. opts.backdropEdge draws the outline the old way, as a backdrop --- edgeFile; see below for why nothing should want that. -local function CreateElementBackdrop(frame, opts) - opts = opts or {} - local fill, outline = opts.fill ~= false, opts.outline ~= false - -- The outline is drawn as our own textures, not a backdrop edgeFile. See - -- ApplyPixelBorder -- a 1px backdrop edge cannot be drawn reliably at a - -- fractional offset, which is the whole missing-border story. - -- - -- This was an opt-in while the hover states were still a problem: a pixel - -- border is not repainted by SetBackdropBorderColor, so converting buttons - -- blind would have silently killed every hover in the GUI. The shim in - -- ApplyPixelBorder settles that -- the frame gets its own - -- SetBackdropBorderColor -- so it is now the default for everything that - -- comes through here, and opts.backdropEdge is the escape hatch for a - -- surface that genuinely needs the old edgeFile. - local usePixel = outline and not opts.backdropEdge - if not frame.SetBackdrop then Mixin(frame, BackdropTemplateMixin) end - local inset = opts.inset - frame:SetBackdrop({ - bgFile = fill and "Interface\\Buttons\\WHITE8x8" or nil, - edgeFile = (outline and not usePixel) and "Interface\\Buttons\\WHITE8x8" or nil, - edgeSize = (outline and not usePixel) and (opts.edgeSize or 1) or nil, - insets = inset and { left = inset, right = inset, - top = inset, bottom = inset } or nil, - }) - if fill then - local c = opts.bgColor - if c then frame:SetBackdropColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) - else frame:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, C_ELEMENT.a) end - end - if outline then - local c = opts.borderColor - if usePixel then - GUI:ApplyPixelBorder(frame, - c and { c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1 } - or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5 }, - opts.edgeSize) - else - RevertPixelBorder(frame) - if c then - frame:SetBackdropBorderColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) - else - frame:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) - end - end - else - -- A backdrop edge disappears on its own when the backdrop is re-issued - -- without one; our textures do not, so an outlined surface re-issued as - -- fill-only has to be told. FlashWidget does exactly this: it re-runs - -- this factory on every pulse, with outline true or false depending on - -- how the caller wants that pulse to read. - GUI:HidePixelBorder(frame) - end - return frame -end - --- The window chrome: the settings window itself, the changelog overlay and the --- dropdown menu panel. Distinct from the public GUI:CreatePanelBackdrop further --- down (dialogs and floating panels, colour-configurable) -- this one is always --- the dark background behind a hard black edge. --- --- Routed through the factory rather than issuing its own backdrop, which is what --- it used to do. It was the last thing in the GUI still drawing a border as an --- edgeFile, and a black 1px line is the case where that reads least badly -- it --- has enough contrast that a split across two device rows softens it instead of --- losing it. Still worth converting: softening on every scroll is what the whole --- pixel border exists to stop, and while this was the last holdout the entire --- snap-registry existed to serve three call sites. -local function CreatePanelBackdrop(frame) - return CreateElementBackdrop(frame, { - bgColor = C_BACKGROUND, - borderColor = { 0, 0, 0, 1 }, - }) -end --- ☠ These two MUST be published here, on the private table, not aliased from --- GUI.CreateElementBackdrop / GUI.CreatePanelBackdrop. Those public names are --- METHOD WRAPPERS (`function GUI:X(...) return X(...) end`) declared later in --- GUI/Widgets.lua -- a different object that happens to share the name. A --- sibling part aliasing the wrapper gets nil at load time, and if it did not, --- the wrapper would end up calling itself. -P.CreateElementBackdrop = CreateElementBackdrop -P.CreatePanelBackdrop = CreatePanelBackdrop - --- Style a ScrollFrameTemplate scrollbar to use the pill-shaped thumb --- All scroll frames must use ScrollFrameTemplate (not UIPanelScrollFrameTemplate) --- --- This used to also quantise the scroll offset to whole device pixels, on the --- reasoning that Blizzard's scrollbar drives the offset as a fraction of the --- range and so parks the content on an arbitrary sub-pixel row. That was true, --- and it did not help: a thin border is soft at SOME offsets no matter which --- offsets you allow, and quantising only changed which ones. The border being --- two device pixels wide is what made the question stop mattering -- it draws --- the same amount of ink wherever it lands. Do not add it back. -local function StyleScrollBar(scrollFrame) - local sb = scrollFrame.ScrollBar - if not sb then return end - - -- Hide track background and track end caps - if sb.Background then sb.Background:Hide() end - if sb.Track then - if sb.Track.Begin then sb.Track.Begin:Hide() end - if sb.Track.End then sb.Track.End:Hide() end - if sb.Track.Middle then sb.Track.Middle:Hide() end - end - - -- Style the pill-shaped thumb — hide default textures, overlay with themed color - if sb.Thumb then - if sb.Thumb.Begin then sb.Thumb.Begin:Hide() end - if sb.Thumb.End then sb.Thumb.End:Hide() end - if sb.Thumb.Middle then sb.Thumb.Middle:Hide() end - if not sb.Thumb.customBg then - local thumb = sb.Thumb:CreateTexture(nil, "ARTWORK") - thumb:SetAllPoints() - thumb:SetColorTexture(0.4, 0.4, 0.4, 0.8) - sb.Thumb.customBg = thumb - end - end - - -- Hide navigation buttons - if sb.Back then sb.Back:Hide() sb.Back:SetSize(1, 1) end - if sb.Forward then sb.Forward:Hide() sb.Forward:SetSize(1, 1) end - - -- Slim width - sb:SetWidth(10) -end -GUI.StyleScrollBar = StyleScrollBar - --- ========================================================================= --- WIDGET FACTORY --- ========================================================================= - -function GUI:CreateHeader(parent, text) - -- Use a frame container so we can position text at bottom (padding above) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(200, 25) - container:Show() - container.rowKind = "header" - -- Factory-owned slot, and fixed so ResolveRowHeight ignores whatever the call - -- site passes. Headers were handed 25 in collapsible groups and 40 in plain - -- ones -- the same widget, two rhythms, across ~200 sites. Owning it here - -- unifies them without touching any of those call sites, which is exactly the - -- rule the other factory rows already follow (see GUI.RowHeight). - container.preferredHeight = GUI.RowHeight.sectionHeader - container.fixedRowHeight = true - - local h = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") - h:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 0, 2) - h:SetText(text) - local c = GetThemeColor() - h:SetTextColor(c.r, c.g, c.b) - h:SetJustifyH("LEFT") - h.UpdateTheme = function() local nc = GetThemeColor() h:SetTextColor(nc.r, nc.g, nc.b) end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, h) - - -- Store text reference - container.text = h - - -- Forward IsShown to ensure layout works - container.GetText = function() return h:GetText() end - - -- SEARCH: Track current section - if DF.Search then - DF.Search:SetCurrentSection(text) - end - - return container -end - --- Collapsible section for grouping related settings. --- Collapsed state is persisted in DandersFramesDB_v2.collapsedGroups keyed by --- `text` (shared store with CreateSettingsGroup's collapsible header), so the --- user's fold preference survives reloads. -function GUI:CreateCollapsibleSection(parent, text, defaultExpanded, width) - local section = CreateFrame("Frame", nil, parent, "BackdropTemplate") - section:SetSize(width or 500, 28) -- Header height - -- Resolve initial expanded state: SavedVariables override the default. - local savedStates = GUI:GetCollapsedGroups() - if text and savedStates[text] ~= nil then - section.expanded = not savedStates[text] - else - section.expanded = defaultExpanded ~= false - end - section.sectionTitleText = text - section.sectionChildren = {} - -- Same contract as CreateHeader's container.GetText: it is how a section is FOUND - -- by title (Search:ScrollToSection, and every GUI:LinkToSetting{ section = ... } - -- cross-link through it). Without it a collapsible section is invisible to those - -- lookups, so a link to it silently does nothing — which is exactly what happened - -- to the Colours page's "Color by Time" links when that box was promoted from a - -- plain header to a collapsible section. - section.GetText = function(self) return self.sectionTitleText end - section.paddingAfter = 8 -- Padding space after header before first child - - -- Header bar with background. Same look as before, via the shared backdrop - -- helper rather than a private copy of it, so it picks up the pixel border - -- (and anything else that lands there) without its own wiring. - GUI:CreateElementBackdrop(section, { - bgColor = { r = C_PANEL.r, g = C_PANEL.g, b = C_PANEL.b, a = 0.8 }, - borderColor = { r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5 }, - }) - - -- Click area - local clickArea = CreateFrame("Button", nil, section) - clickArea:SetAllPoints() - clickArea:EnableMouse(true) - - -- Expand/collapse arrow icon - section.arrow = section:CreateTexture(nil, "OVERLAY") - section.arrow:SetPoint("LEFT", 8, 0) - section.arrow:SetSize(12, 12) - if section.expanded then - section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - else - section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") - end - section.arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - -- Section title - section.title = section:CreateFontString(nil, "OVERLAY", "DFFontNormal") - section.title:SetPoint("LEFT", 26, 0) - section.title:SetText(text) - local c = GetThemeColor() - section.title:SetTextColor(c.r, c.g, c.b) - section.title.UpdateTheme = function() - if section.previewDimmed then - section.title:SetTextColor(0.5, 0.5, 0.5) - else - local nc = GetThemeColor() - section.title:SetTextColor(nc.r, nc.g, nc.b) - end - end - -- Grey the header title when the section's feature is disabled (driven by - -- the preview wiring). Routes through UpdateTheme so theme changes respect it. - section.SetPreviewDimmed = function(self, dimmed) - self.previewDimmed = dimmed and true or false - self.title.UpdateTheme() - end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, section.title) - - -- Optional inline tag — small yellow text placed after the title to - -- stand out as a status summary (e.g. "[Normal Dispels]"). Call - -- section:SetTag(text) at any time; pass nil or empty string to clear. - section.tag = section:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - section.tag:SetPoint("LEFT", section.title, "RIGHT", 8, 0) - section.tag:SetTextColor(1, 0.82, 0, 1) -- WoW standard gold/yellow - section.tag:SetText("") - section.SetTag = function(self, text) - if text and text ~= "" then - self.tag:SetText(text) - self.tag:Show() - else - self.tag:SetText("") - self.tag:Hide() - end - end - - -- SEARCH: Track current section - if DF.Search then - DF.Search:SetCurrentSection(text) - end - - -- Toggle function - section.Toggle = function(self) - self.expanded = not self.expanded - if self.expanded then - self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - else - self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") - end - -- Persist collapsed state to SavedVariables (only store true, remove when expanded) - if self.sectionTitleText then - local saved = GUI:GetCollapsedGroups() - saved[self.sectionTitleText] = (not self.expanded) or nil - end - - -- Trigger layout refresh (RefreshStates handles show/hide based on expanded state) - if parent.RefreshStates then - parent:RefreshStates() - end - end - - -- Register child widgets to this section - section.RegisterChild = function(self, widget) - table.insert(self.sectionChildren, widget) - widget.parentSection = self - - -- Use a marker to check section state during RefreshStates - widget.collapsibleSection = self - end - - -- Optional header preview thumbnails — a right-aligned row of small icon - -- swatches on the header bar, used to show the actual icon(s) a section - -- controls (e.g. the Role Icon section previews the Tank/Healer/DPS icons in - -- the currently selected style). Always visible on the header, so the page - -- reads as a gallery whether sections are expanded or collapsed. - -- - -- icons: array of entries, each EITHER an icon or a text label: - -- { texture = "atlas-or-path", coords = {l,r,t,b}?, desaturate = bool? } - -- { text = "MT", desaturate = bool? } - -- Icon entries are fixed-width swatches; text entries are sized to the - -- string. Entries flow right-to-left from the header's right edge so the - -- first entry sits leftmost. nil/empty clears the preview. - section.previewIcons = {} - section.SetPreviewIcons = function(self, icons) - local pool = self.previewIcons - local n = icons and #icons or 0 - local SIZE, GAP, RIGHT_INSET = 18, 4, -10 - local x = RIGHT_INSET - for i = n, 1, -1 do -- right-to-left so entry 1 ends up leftmost - local data = icons[i] - local slot = pool[i] - if not slot then - slot = CreateFrame("Frame", nil, self) - slot:SetHeight(SIZE) - slot.tex = slot:CreateTexture(nil, "OVERLAY") - slot.tex:SetAllPoints() - slot.fs = slot:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - slot.fs:SetAllPoints() - slot.fs:SetJustifyH("CENTER") - pool[i] = slot - end - local dim = data.desaturate and true or false - local w = SIZE - if data.text and data.text ~= "" then - slot.tex:Hide() - slot.fs:SetText(data.text) - if dim then - slot.fs:SetTextColor(0.5, 0.5, 0.5, 1) - elseif data.color then - slot.fs:SetTextColor(data.color.r or 1, data.color.g or 1, data.color.b or 1, data.color.a or 1) - else - slot.fs:SetTextColor(1, 0.82, 0, 1) - end - slot.fs:Show() - w = math.max(SIZE, (slot.fs:GetStringWidth() or 0) + 4) - else - slot.fs:Hide() - -- data.texture may be an atlas name or a texture path; the helper - -- prefers the atlas and falls back to the path (+ optional coords). - local co = data.coords - DF:SetIconTextureOrAtlas(slot.tex, data.texture, co and co[1], co and co[2], co and co[3], co and co[4]) - slot.tex:SetDesaturated(dim) - -- Optional per-entry inset: textures that fill their cell edge-to-edge - -- (e.g. raid-target markers) read bigger than the padded status-icon - -- atlases. data.inset shrinks the swatch to match. - local pad = data.inset or 0 - slot.tex:ClearAllPoints() - slot.tex:SetPoint("TOPLEFT", slot, "TOPLEFT", pad, -pad) - slot.tex:SetPoint("BOTTOMRIGHT", slot, "BOTTOMRIGHT", -pad, pad) - slot.tex:Show() - end - slot:SetWidth(w) - slot:ClearAllPoints() - slot:SetPoint("RIGHT", self, "RIGHT", x, 0) - slot:Show() - x = x - w - GAP - end - for i = n + 1, #pool do pool[i]:Hide() end - end - - -- Hover effects - clickArea:SetScript("OnEnter", function() - section:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.8) - end) - clickArea:SetScript("OnLeave", function() - section:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.8) - end) - clickArea:SetScript("OnClick", function() - section:Toggle() - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - - return section -end - --- ========================================================================= --- SETTINGS GROUP - Visible container that groups related settings together --- Ensures settings never get split across columns --- ========================================================================= --- Collapsed state persistence (stored in SavedVariables, survives logout) --- Lazily initialized from DandersFramesDB_v2.collapsedGroups on first access -function GUI:GetCollapsedGroups() - if not DandersFramesDB_v2 then return {} end - if not DandersFramesDB_v2.collapsedGroups then - DandersFramesDB_v2.collapsedGroups = {} - end - return DandersFramesDB_v2.collapsedGroups -end \ No newline at end of file +local addonName, DF = ... +local GUI = {} +DF.GUI = GUI +local L = DF.L + +-- Mutable module state. +-- These four were file-scope locals, and two of them are read and written more +-- than 6,000 lines apart. That is harmless in one file and silently broken the +-- moment the file is split: a `local` re-declared in the second half becomes an +-- independent variable, so the two halves stop sharing state with no error. +-- Holding them on one table keeps a future split honest -- each part reads the +-- same table via `local S = GUI._state`. Read-only aliases (the C_* colours, L, +-- GUI itself) do not need this: re-declaring those yields the same value. +-- Private by convention -- nothing outside this file should touch GUI._state. +local S = {} +GUI._state = S + +-- Shared private helpers. +-- A `local function` is invisible to any other file, so a helper used thousands +-- of lines from its declaration pins this file together: it cannot be split +-- while a later section still calls it. Publishing the wide-reaching ones here +-- lets a sibling file re-declare `local X = GUI._priv.X` -- the same function +-- object, so no call site changes and behaviour is identical. +-- Only helpers spanning >1000 lines are listed; narrow ones stay private. +-- Private by convention -- nothing outside GUI/ should touch GUI._priv. +local P = {} +GUI._priv = P + +-- ========================================================================= +-- MODERN UI CONSTANTS & STYLING (Matching Original v2.3.8) +-- ========================================================================= + +local C_BACKGROUND = {r = 0.08, g = 0.08, b = 0.08, a = 0.95} -- Dark charcoal +local C_PANEL = {r = 0.12, g = 0.12, b = 0.12, a = 1} -- Slightly lighter +local C_ELEMENT = {r = 0.18, g = 0.18, b = 0.18, a = 1} -- Element backgrounds +local C_BORDER = {r = 0.25, g = 0.25, b = 0.25, a = 1} -- Subtle borders +local C_ACCENT = {r = 0.45, g = 0.45, b = 0.95, a = 1} -- Party Purple-Blue +local C_RAID = {r = 1.0, g = 0.5, b = 0.2, a = 1} -- Raid Orange +local C_HOVER = {r = 0.22, g = 0.22, b = 0.22, a = 1} +local C_TEXT = {r = 0.9, g = 0.9, b = 0.9, a = 1} +local C_TEXT_DIM = {r = 0.6, g = 0.6, b = 0.6, a = 1} +local C_WARNING = {r = 0.95, g = 0.35, b = 0.35, a = 1} -- Soft red: behaviour-change / caution notes + +-- Exported palette: other files should theme against these shared tables instead +-- of re-declaring private copies or hardcoding the raw numbers. These are the +-- SAME table references as the locals above. For the mode-aware accent, use +-- GUI.GetThemeColor() (returns party purple or raid orange). +GUI.Colors = { + background = C_BACKGROUND, + panel = C_PANEL, + element = C_ELEMENT, + border = C_BORDER, + accent = C_ACCENT, -- party purple + raid = C_RAID, -- raid orange + hover = C_HOVER, + text = C_TEXT, + textDim = C_TEXT_DIM, + warning = C_WARNING, -- soft red for behaviour-change / caution notes +} + +-- Dialog chrome. Popup.lua is a standalone dialog rather than a settings page and +-- wanted the same handful of extras on top of the shared neutrals — so it had +-- grown a private copy of the WHOLE palette (11 hardcoded colours), matching +-- today only by luck. (WizardBuilder.lua was the other such dialog, since +-- deleted as dead code — hence "three separate copies" in the note below.) +-- One owner: the neutrals below are the SAME tables as GUI.Colors, so they +-- theme-track in lockstep, and only what genuinely differs is declared here. +-- Read-only by convention — these tables are shared, so nothing may mutate them. +GUI.DialogColors = { + -- Dialogs use the SAME ground as the pages. This was a bespoke 0.97 in three + -- separate copies, a shade denser than the pages' 0.95 for no reason anyone + -- could point at; consolidating the copies made the difference visible and + -- it went. Chrome now reads identically whether it's a page or a dialog. + background = C_BACKGROUND, + panel = C_PANEL, + element = C_ELEMENT, + border = C_BORDER, + accent = C_ACCENT, -- fallback only; live dialogs read GetThemeColor() + hover = C_HOVER, + text = C_TEXT, + textDim = C_TEXT_DIM, + selected = {r = 0.28, g = 0.28, b = 0.45, a = 1}, + -- Status pair for dialog content (valid/invalid rows, ok/error dots). + green = {r = 0.2, g = 0.9, b = 0.2}, + red = {r = 0.9, g = 0.25, b = 0.25}, + orange = {r = 0.85, g = 0.55, b = 0.1}, +} + +-- Canonical row heights (the "airier" scale). A fixed-height widget factory stamps its own slot +-- height onto the widget (widget.preferredHeight + widget.fixedRowHeight), so the layout uses THAT +-- and a call-site number can't make the same widget type render at a different height on a different +-- page. New callers can omit the height entirely; legacy call-site numbers on fixed widgets are +-- ignored (harmless, strippable later). Variable widgets (labels, headers, spacers) are NOT stamped +-- and keep whatever height they are given. One place to retune the whole GUI's vertical rhythm. +-- THE vertical rhythm of the whole GUI. +-- +-- These are SLOT heights, not gaps -- LayoutChildren stacks rows flush +-- (y = y - height), so the gap the eye sees between two rows is: +-- +-- (slot - content) of the row above + (content's top inset) of the row below +-- +-- which means a row whose content is short inside a tall slot silently gets a big +-- gap. That is how the GUI ended up with a 4x spread. /df debug gapcheck measured it +-- across four pages (267 rows), and the content heights came back IDENTICAL on +-- every page, so the slots can be derived rather than guessed: +-- +-- kind content old slot old gap new slot new gap +-- slider 32.0 55 23.0 46 14 +-- dropdown 39.8 55 15.2 54 14 +-- editbox 39.0 55 16.0 53 14 +-- colorpicker 23.9 30 6.1 38 14 +-- checkbox 18.2 30 11.8* 35 14* +-- header 11.9 34 / 40 11.0 / 17.0 37 14 +-- +-- So: ONE gap, and every slot is content + RowGap. 14 is not arbitrary -- it is +-- what the dropdown rows already had (15.2), the one spacing Krathe confirmed +-- reads correctly. Sliders lose 9px of slack, colour pickers gain 8. +-- +-- * the checkbox's content sits 2.9px below its slot top, so a row landing ON a +-- checkbox reads 14 + 2.9. Zeroing that would mean re-anchoring the checkbox +-- art itself, which moves the tick 3px for 3px -- not worth it. +-- +-- A header keeps its 11.1px top inset, so the gap ABOVE a header is 14 + 11.1. +-- That is deliberate: a section title wants air above it and to sit close to +-- what it labels. +GUI.RowGap = 14 + +-- ...with ONE exception: a RUN of the same COMPACT row type closes up. +-- +-- A uniform gap everywhere is right between DIFFERENT kinds -- that is the +-- boundary the eye uses to tell one control apart from the next. But eight +-- checkboxes in a column are one list, not eight things, and 14 between each of +-- them reads as a stack of unrelated rows. So consecutive rows of the same +-- compact kind get RowGapTight, and the first row of a different kind after them +-- gets the full RowGap back. Same spacing between TYPES, tighter within a type. +-- +-- Compact means the label sits INLINE with the control (a checkbox's text is +-- beside its tick). Slider, dropdown and edit box are deliberately NOT compact: +-- their label sits ABOVE the control, so tightening the gap there would push the +-- next row's label into the control above it -- the same reasoning already +-- recorded on labelPad, and the reason a stack of sliders needs real air even +-- though a stack of checkboxes does not. +GUI.RowGapTight = 8 +GUI.RowCompact = { + checkbox = true, + toggle = true, + colorpicker = true, +} + +-- PAGE-level spacing, a different axis from the row rhythm above: AddSpace +-- inserts a spacer into the page's COLUMN flow, between groups, not between rows +-- inside one. Groups already carry a 10px margin of their own, so these stack ON +-- TOP of that -- a `section` break reads as 20 between two groups, a `block` as +-- 30. +-- +-- The audit found 58 AddSpace calls passing 9 different numbers, which looked +-- worse than it was: classified by INTENT rather than by value, two idioms cover +-- 42 of them and both were already internally consistent -- +-- +-- section break (after `currentSection = nil`, or a bare gap after a group) +-- 19/19 at 10, plus 7 more following an Add() +-- before the See-Also links at the foot of a page +-- 15/16 at 20, one stray 15 +-- +-- The real inconsistency was three files each picking their own number for the +-- SAME intent (NicknamesPage used 12 throughout), not 9 competing rhythms. +GUI.Space = { + section = 10, -- between logical sections in a page column + block = 20, -- before a distinct trailing block (the See-Also links) + footer = 12, -- below the See-Also bar when it is parked at the viewport bottom +} + +GUI.RowHeight = { + checkbox = 35, -- 2.9 top inset + 18.2 content + RowGap + slider = 46, -- 32.0 content + RowGap (was 55: a slot sized for the dropdown) + dropdown = 54, -- 39.8 content + RowGap + colorpicker = 38, -- 23.9 content + RowGap + editbox = 53, -- 39.0 content + RowGap (box at -15, h24) + toggle = 35, -- two-state switch; same content as a checkbox, same row + -- Labels are VARIABLE height (they wrap), so they have no fixed row — but they do + -- have fixed CHROME, which CreateLabel adds to the measured text height: the 5px top + -- inset its FontString sits at, plus the gap below. That gap IS the whole visible + -- space to the next row — LayoutChildren stacks rows flush (y = y - height) and a + -- labelled control (dropdown/slider/editbox) puts its own label at TOPLEFT 0,0 — so a + -- smaller pad reads as a blurb crowding the control it describes. + labelPad = 5 + GUI.RowGap, + -- EVERY section header, collapsible or not. CreateHeader's container is 25 + -- tall with its text pinned to the BOTTOM, so its 11.1px of internal padding + -- all sits ABOVE the text and this slot minus 25 is the entire visible gap + -- below it -- at exactly 25 there is none. + -- + -- The old split was never designed: collapsible groups were handed 25 (no + -- gap) and plain ones 40, across both files, for the same construct. Rather + -- than sweep ~200 call sites, CreateHeader now marks itself fixedRowHeight, + -- so ResolveRowHeight IGNORES the literal a call site passes and every header + -- lands here. That is the same rule the other factory rows already follow. + sectionHeader = 11.1 + 11.9 + GUI.RowGap, -- top inset + text + the gap +} + +-- Resolve the layout slot height for a widget being added to a group/page. Fixed-height widgets +-- own their height (drift-proof); everything else uses the height it was handed, then the widget's +-- own preferred height, then a sane default. +local function ResolveRowHeight(widget, height) + if widget and widget.fixedRowHeight and widget.preferredHeight then + return widget.preferredHeight + end + return height or (widget and widget.preferredHeight) or 55 +end +GUI.ResolveRowHeight = ResolveRowHeight + + +-- "/df debug guiwidth" — width ground truth for the page on screen: every top-level child and +-- every settings-group child with its live width, flagging any that is non-positive or +-- narrower than its group allows. +-- Use it to TELL APART the two causes of truncated label text, which look identical: +-- * a real width fault -> frames show up flagged here; +-- * a stale FontString wrap -> every width reads correct and the count is 0, yet the +-- text is visibly ellipsised (and scrolling the window snaps it back, because that +-- re-renders the string). That is CreateLabel's Reflow case, not a sizing bug. +-- Recorded because the second one cost two wrong fixes before this dump ruled out the first. +function GUI:DebugDumpWidths() + local page = GUI.CurrentPageName and GUI.Pages[GUI.CurrentPageName] + if not page or not page.children then + DF:Say("GUI width", "no built page on screen", "WARN") + return + end + local content = GUI.contentFrame and GUI.contentFrame:GetWidth() or -1 + local o = DF:Out("GUI Width", ("page '%s'"):format(GUI.CurrentPageName)) + o:Section("Widths") + o:Field("content frame", ("%.0f"):format(content), content > 0 and "GOOD" or "BAD") + o:Field("scroll child", ("%.0f"):format(page.child and page.child:GetWidth() or -1), "NEUTRAL") + + o:Section("Widgets") + local bad = 0 + local function flag(w, limit) + if not w or w <= 0 then bad = bad + 1; return "|cffff4444 <== NON-POSITIVE|r" end + if limit and w < limit - 1 then bad = bad + 1; return "|cffffcc00 <== NARROW|r" end + return "" + end + for i, widget in ipairs(page.children) do + if widget.isSettingsGroup then + local gw = widget:GetWidth() or 0 + local inner = gw - (widget.padding or 10) * 2 + print((" [%d] group w=%.0f inner=%.0f col=%s shown=%s%s") + :format(i, gw, inner, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(gw))) + for j, entry in ipairs(widget.groupChildren or {}) do + local c = entry.widget + local cw = c and c:GetWidth() or 0 + print((" (%d) w=%.0f h=%.0f shown=%s%s") + :format(j, cw, entry.height or -1, tostring(c and c:IsShown()), flag(cw, inner))) + end + else + local w = widget:GetWidth() or 0 + print((" [%d] widget w=%.0f col=%s shown=%s%s") + :format(i, w, tostring(widget.layoutCol), tostring(widget:IsShown()), flag(w))) + end + end + o:Section("Result") + o:Field("suspect frames", bad, bad > 0 and "WARN" or "GOOD") + o:Siblings("guiwidth") +end + +DF.SectionRegistry = DF.SectionRegistry or {} + + +-- Track selected mode +GUI.SelectedMode = "party" + +-- Registry of tabs that should show a "New" badge until opened. +-- Add tab IDs here for new features; the badge auto-hides once viewed. +-- Reset each release cycle to the tabs that are new since the last stable +-- (prior entries are persisted as seen and would otherwise show stale badges). +GUI.NewTabs = { + ["text_designer"] = true, + ["general_nicknames"] = true, +} + +-- Registry of section headers (inside a tab) that should show a "New" badge +-- until the user visits the tab and then navigates away. Keyed by +-- "." so entries are unambiguous across tabs. +-- The badge is created by GUI:AddSectionNewBadge and cleared by SelectTab +-- when the user leaves the owning tab (persisted via seenSections). +GUI.NewSections = { +} + +-- Live-tracked badges pending a "seen" mark, keyed by tabName → { key = badge }. +-- Populated by AddSectionNewBadge, drained by SelectTab on tab leave. +GUI.pendingSectionBadges = {} + + +-- Pages that remain fully accessible regardless of whether party or raid +-- mode is disabled via General settings. All other mode-specific tabs +-- are greyed out and non-interactive when viewing a disabled mode. +-- Auto Layouts is intentionally NOT whitelisted: it edits per-profile +-- settings that would have no effect if all frames are disabled. +GUI.AlwaysAccessiblePages = { + ["general_settings"] = true, -- the toggles themselves + ["profiles_manage"] = true, + ["profiles_importexport"] = true, + ["debug_console"] = true, + ["indicators_targetedlist"] = true, + ["indicators_personal_targeted"] = true, +} + + + +-- Track currently open dropdown menu (only one can be open at a time) +S.currentOpenDropdown = nil + +-- Close any currently open dropdown +local function CloseOpenDropdown() + if S.currentOpenDropdown and S.currentOpenDropdown:IsShown() then + S.currentOpenDropdown:Hide() + end + S.currentOpenDropdown = nil +end +P.CloseOpenDropdown = CloseOpenDropdown + +-- Helper to get current theme color +-- The theme colour for an EXPLICIT mode. Use this for any surface that belongs to +-- a mode rather than to whatever page the options window happens to be showing -- +-- movers, pinned containers -- so a raid surface stays orange while the window is +-- on a party page. GetThemeColor() below is the follow-the-window variant. +local function GetThemeColorFor(isRaid) + if isRaid then return C_RAID else return C_ACCENT end +end +GUI.GetThemeColorFor = GetThemeColorFor + +local function GetThemeColor() + return GetThemeColorFor(GUI.SelectedMode == "raid") +end +GUI.GetThemeColor = GetThemeColor + +-- Physical pixels per UI unit, measured from the frame's OWN effective scale. +-- The GUI window carries a user scale (guiScale) on top of UIParent's and is +-- freely resizable, so this is almost never 1 and cannot be read from the +-- addon-wide DF:GetPixelScale (which is relative to UIParent). +local function PixelsPerUnit(frame) + local eff = frame and frame.GetEffectiveScale and frame:GetEffectiveScale() + local _, physH = GetPhysicalScreenSize() + if not (eff and eff > 0 and physH and physH > 0) then return nil end + return eff * physH / 768 +end +P.PixelsPerUnit = PixelsPerUnit + +-- ⚠ THERE IS NO RUNTIME GEOMETRY CORRECTION, AND THERE SHOULD NOT BE ONE. +-- +-- This GUI used to carry a registry that nudged every bordered box onto the +-- pixel grid after the layout had placed it. It is gone, and the reason is +-- structural rather than a bug in the implementation: Lua-side snapping can only +-- correct a frame at REST, and the symptom it was chasing -- a thin border going +-- soft -- is at its worst while the content is MOVING, during a scroll. +-- Correcting mid-scroll does not fix that, it adds a half-pixel jump on top of +-- it, which reads as flicker. It also cost us the button-row drift, where an +-- unordered sweep corrected chained anchors in a different order each pass. +-- +-- Three things replaced it, and between them they are enough: +-- * the LAYOUT picks whole-pixel numbers in the first place (SnapLen below, at +-- the point an offset, width or height is chosen). No runtime cost, nothing +-- to drift, nothing to flicker -- it just picks better numbers. +-- * the CLIP SURFACES are snapped (page viewport insets, the content panel, +-- the nav chain). Those were real bugs: a box clipped by a fractional edge +-- loses part of its border no matter how well the box itself is placed. +-- * the BORDER is drawn as two device pixels of our own texture rather than a +-- one-unit backdrop edge, so it cannot land badly in the first place. See +-- PIXEL BORDER further down -- that is the part that actually solved it. + +-- Round a LENGTH or OFFSET (in UI units) to a whole number of device pixels at +-- the scale `frame` is drawn at. This is the layout-side half of the pixel-grid +-- work, and the half that was missing. +-- +-- A box lands on the grid only if the numbers it was GIVEN were whole pixels. +-- They usually are not: a group's inner width is (its width - 2 * padding), and +-- its padding is 10 UI units, which is a whole number of device pixels only when +-- the GUI is at exactly 1:1 scale. Everywhere else the box's right and bottom +-- edges fall mid-pixel, and anything clipped by them loses part of itself. +-- +-- Snapping at the point the number is CHOSEN fixes that by construction, for +-- everything a layout places, with no per-widget opt-in for anyone to forget. +local function SnapLen(frame, v) + if not v then return v end + local ppu = PixelsPerUnit(frame) + if not ppu then return v end + return math.floor(v * ppu + 0.5) / ppu +end +GUI.SnapLen = SnapLen + +-- SnapLen rounds to the NEAREST device pixel, which can round a width DOWN and +-- clip the text it was measured from. This rounds up instead: for anything whose +-- length comes from a measurement (a button sized to its own label), the width +-- has to be at least what was asked for, and on the grid. +local function SnapLenUp(frame, v) + if not v then return v end + local ppu = PixelsPerUnit(frame) + if not ppu then return v end + return math.ceil(v * ppu) / ppu +end +GUI.SnapLenUp = SnapLenUp + +-- Round a HEIGHT to an EVEN number of device pixels (minimum two). +-- +-- Rows of controls are chained with centre-aligning anchors -- SetPoint("RIGHT", +-- prev, "LEFT", gap, 0) aligns the two frames' vertical CENTRES, and the toolbar +-- does the same with LEFT/RIGHT. A frame's centre is bottom + height/2, so if the +-- height is an ODD number of device pixels the centre falls on a half pixel, and +-- every frame chained off it inherits that half-pixel offset no matter how well +-- its own edges are snapped. Even heights make the whole row land together. +-- +-- Used for control heights, which the factories set once at construction. Widths +-- do not need this: nothing centre-anchors horizontally off a control. +local function SnapHeightEven(frame, v) + if not v then return v end + local ppu = PixelsPerUnit(frame) + if not ppu then return v end + return math.max(2, math.floor(v * ppu / 2 + 0.5) * 2) / ppu +end +GUI.SnapHeightEven = SnapHeightEven + +-- ============================================================ +-- Stamped once when this file loads, so it identifies THIS session (and therefore +-- this build) for the pixelcheck and gapcheck captures. Declared HERE, above the +-- first function that reads it: a local declared further down the file is not an +-- upvalue for a function defined above it -- the read would silently resolve to a +-- nil global and every capture would look like a new session. +local GAP_SESSION = date and date("%Y-%m-%d %H:%M:%S") or "?" + +-- /df debug pixelcheck -- measure, don't guess +-- +-- The "top border of a box goes missing until you scroll" bug has now been +-- diagnosed twice from reasoning about the layout and fixed twice, and it is +-- still here. The two remaining explanations look IDENTICAL in a screenshot: +-- +-- (a) sub-pixel -- the box's top edge lands between two device rows, so the +-- 1px line is filtered across both at half intensity; +-- (b) clipping -- the box's top edge sits within a pixel of the ScrollFrame's +-- own clip boundary, so the line is simply cut off. +-- +-- Scrolling "fixes" both, which is exactly why the screenshot can't separate +-- them. This reports the numbers for the open page so the next change is aimed. +-- Debug output: deliberately raw, like the other /df debug dumps. +-- ============================================================ + +-- Signed distance from `v` (UI units) to the nearest whole device pixel. +local function PixelOffsetOf(v, ppu) + if not v or not ppu then return nil end + local px = v * ppu + return px - math.floor(px + 0.5) +end + +local function DescribeFrame(f) + -- Prefer a human label so the output names the card, not "Frame". + for _, key in ipairs({ "label", "title", "titleText", "Text", "header" }) do + local o = f[key] + if type(o) == "table" and o.GetText then + local t = o:GetText() + if t and t ~= "" then return t end + end + end + if f.GetRegions then + for _, r in ipairs({ f:GetRegions() }) do + if r.GetObjectType and r:GetObjectType() == "FontString" then + local t = r:GetText() + if t and t ~= "" then return t end + end + end + end + return (f.GetObjectType and f:GetObjectType()) or "Frame" +end + +-- Every SHOWN descendant carrying a border -- the only frames that can exhibit +-- this bug (a fill-only surface has no edge to lose). +-- +-- Both mechanisms, deliberately. Testing edgeFile alone was right when that was +-- the only way a border got drawn; now that most surfaces own their four +-- textures instead, an edgeFile-only sweep would report a converted page as +-- having no bordered frames at all and the probe would quietly stop being able +-- to see the thing it exists to measure. +local function CollectBorderedFrames(root, out, depth) + if not root or depth > 10 then return out end + for _, child in ipairs({ root:GetChildren() }) do + if child:IsShown() then + local bd = child.GetBackdrop and child:GetBackdrop() + if (bd and bd.edgeFile) or child._pxBorder then out[#out + 1] = child end + CollectBorderedFrames(child, out, depth + 1) + end + end + return out +end + +-- What sits ON the box's top border row. +-- +-- Geometry came back perfect on BOTH a broken and a working page (BOX 0/n, +-- top+0.00, edge=1.00px, nothing near the clip edge), so the difference is not +-- anything the box itself measures. The one thing that tracked the symptom was +-- how far the first box sits from the top: 62.7px on a page that loses its +-- border vs 76.8px on one that doesn't -- 14.1px apart, which at 1.4062 px/unit +-- is exactly the 10-unit group padding. A box that starts hard against whatever +-- is above it loses the line; one with a gap keeps it. That is the signature of +-- the row above covering it, so: find any sibling whose rect spans the box's top +-- border row, and report it with its frame level. +local function FindTopRowOverlaps(box) + local out = {} + local top, bL, bR = box:GetTop(), box:GetLeft(), box:GetRight() + local parent = box:GetParent() + if not (top and bL and bR and parent) then return out end + -- ~1.5 device px expressed in UI units: the border row plus a hair. + local band = 1.5 / (PixelsPerUnit(box) or 1) + for _, sib in ipairs({ parent:GetChildren() }) do + if sib ~= box and sib.IsShown and sib:IsShown() then + local sT, sB, sL, sR = sib:GetTop(), sib:GetBottom(), sib:GetLeft(), sib:GetRight() + if sT and sB and sL and sR + and sB <= top + band and sT >= top - band -- spans the border row + and sR > bL and sL < bR then -- and overlaps horizontally + out[#out + 1] = ("%s(lvl%d)"):format( + DescribeFrame(sib):sub(1, 16), sib:GetFrameLevel() or 0) + end + end + end + return out +end + +function GUI.PixelCheck() + local page, pageName + for name, p in pairs(GUI.Pages or {}) do + if p.IsShown and p:IsShown() then page, pageName = p, name; break end + end + if not page then + DF:Say("Pixel check", "no settings page is open", "WARN") + return + end + + local ppu = PixelsPerUnit(page) + if not ppu then + DF:Say("Pixel check", "scale unresolved — is the window shown?", "WARN") + return + end + + local scroll = page.GetVerticalScroll and page:GetVerticalScroll() or 0 + local scrollOff = PixelOffsetOf(scroll, ppu) + local clipTop = page:GetTop() + + local o = DF:Out("Pixel Check", ("page %s"):format(tostring(pageName))) + o:Section("Scale") + o:Field("effective scale", ("%.4f"):format(page:GetEffectiveScale() or 0), "NEUTRAL") + o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") + + o:Section("Scroll") + -- Reported, not judged. This used to print a red NOT SNAPPED when the offset + -- was off-grid, back when the scroll offset was quantised and being off-grid + -- meant something had gone wrong. Nothing quantises it now -- a 2px border + -- draws the same ink at any offset -- so an off-grid figure here is the + -- normal state of a scrolled page, and flagging it as a fault sends the next + -- person reading this output after a bug that is not there. + o:Field("offset", ("%.3f (%.2f px off grid)"):format(scroll, scrollOff or 0), "NEUTRAL") + o:Line("Off-grid here is expected — nothing quantises the scroll offset.", "NEUTRAL") + + -- THE CLIP BOUNDARY ITSELF. Earlier runs measured each box's DISTANCE to this + -- edge but never whether the edge is on-grid. A ScrollFrame clips to its own + -- rect, so if that rect's top sits on a fractional device row the cut takes a + -- partial row off whatever is nearest it -- which is exactly the reported + -- pattern: pages whose boxes start at the top lose the border, pages with a + -- gap do not. The scroll child is included because content is positioned + -- against it, so its phase is what every box inherits. + local dPageTop = PixelOffsetOf(page:GetTop(), ppu) + local dPageBot = PixelOffsetOf(page:GetBottom(), ppu) + local dPageH = PixelOffsetOf(page:GetHeight(), ppu) + -- Flag EITHER edge. This used to test only the top, so it printed + -- "bot-0.38" on every run for weeks and never once marked it -- and an + -- unflagged number in a wall of numbers is an invisible one. The bottom + -- edge clips whatever rests against it just as hard as the top does, which + -- is the entire See-Also footer bug. + local badTop = dPageTop and math.abs(dPageTop) > 0.05 + local badBot = dPageBot and math.abs(dPageBot) > 0.05 + o:Section("Viewport", "the clip edge") + o:Field("offsets", ("top%+.2f bot%+.2f h%+.2f"):format(dPageTop or 0, dPageBot or 0, dPageH or 0), + (badTop or badBot) and "BAD" or "GOOD") + if badTop or badBot then + o:Line(("Clip edge OFF-GRID (%s) — it shaves a partial row off whatever rests against it."):format( + badTop and (badBot and "top and bottom" or "top") or "bottom"), "BAD") + end + local kid = page.child or (page.GetScrollChild and page:GetScrollChild()) + if kid then + local kppu = PixelsPerUnit(kid) or ppu + local kidBad = math.abs(PixelOffsetOf(kid:GetTop(), kppu) or 0) > 0.05 + o:Field("scroll child", ("top%+.2f w%+.2f"):format( + PixelOffsetOf(kid:GetTop(), kppu) or 0, PixelOffsetOf(kid:GetWidth(), kppu) or 0), + kidBad and "BAD" or "GOOD") + if kidBad then + o:Line("Content is positioned against this, so every box inherits its phase.", "BAD") + end + end + + -- "Is it the section BOXES or the controls inside them?" is the question the + -- first version of this could not answer -- it ranked worst-first and the top + -- of the list was all controls, so the groups never showed. Classify, and + -- report the boxes separately no matter where they rank. + local function KindOf(f) + if f.LayoutChildren then return "BOX" end -- CreateSettingsGroup + if f.slider then return "slider" end + return (f.GetObjectType and f:GetObjectType()) or "frame" + end + + local frames = CollectBorderedFrames(page.child or page, {}, 0) + local rows, offGrid, nearClip = {}, 0, 0 + local byKind = {} + for _, f in ipairs(frames) do + local top, bottom, h = f:GetTop(), f:GetBottom(), f:GetHeight() + local fppu = PixelsPerUnit(f) or ppu + local dTop = PixelOffsetOf(top, fppu) + local dBot = PixelOffsetOf(bottom, fppu) + local dH = PixelOffsetOf(h, fppu) + -- Distance from the scroll viewport's top clip edge, in device pixels. + local clipGap = (top and clipTop) and ((clipTop - top) * fppu) or nil + if dTop and math.abs(dTop) > 0.05 then offGrid = offGrid + 1 end + if clipGap and clipGap > -1.5 and clipGap < 1.5 then nearClip = nearClip + 1 end + -- Edge THICKNESS, which is the leg two earlier revisions of this probe + -- did not capture and the one that turned out to matter. A box can + -- measure a perfect 0.00 on every edge and still lose its border if the + -- edge is drawn a fractional number of device pixels wide: it bleeds + -- into the next row at partial intensity, and a settings-group edge is + -- only ~8% alpha over a 3% fill, so both halves can land under the + -- visibility floor. Alpha is reported alongside because it sets that + -- floor. + -- + -- A pixel border reports its own thickness instead: it is authored in + -- device pixels, so it is a whole number by construction and edgeFrac + -- is 0 -- which is exactly the point of it, and worth being able to SEE + -- next to a surface still on the old edge. + local bdInfo = f.GetBackdrop and f:GetBackdrop() + local edgeUnits = bdInfo and bdInfo.edgeSize or nil + -- f._pxDevicePx, not a recomputation from PX_BORDER_THICKNESS: that + -- constant is declared several hundred lines below this probe, so + -- naming it here would resolve to a nil GLOBAL, silently. Reading what + -- LayoutPixelBorder actually drew is both safer and more truthful. + local edgePx = edgeUnits and (edgeUnits * fppu) or f._pxDevicePx or nil + local edgeFrac = edgePx and (edgePx - math.floor(edgePx + 0.5)) or nil + -- NOT `local _,_,_,a = (f:GetBackdropBorderColor())` -- the parentheses + -- truncate a multi-return to ONE value, so alpha came back nil every time + -- and every row printed "a=n/a". Alpha is the whole point here: a + -- settings-group edge is ~8% over a 3% fill, so it has almost no margin. + local borderA + if f.GetBackdropBorderColor then + local _, _, _, a = f:GetBackdropBorderColor() + borderA = a + end + local kind = KindOf(f) + local bad = (dTop and math.abs(dTop) > 0.05) or false + local k = byKind[kind] + if not k then k = { n = 0, bad = 0 }; byKind[kind] = k end + k.n = k.n + 1 + if bad then k.bad = k.bad + 1 end + rows[#rows + 1] = { + label = DescribeFrame(f), kind = kind, bad = bad, + frame = f, level = f.GetFrameLevel and f:GetFrameLevel() or nil, + dTop = dTop or 0, dBot = dBot or 0, + dH = dH or 0, clipGap = clipGap, + edgePx = edgePx, edgeFrac = edgeFrac, alpha = borderA, + score = math.max(math.abs(dTop or 0), + (clipGap and math.abs(clipGap) < 1.5) and 1 or 0), + } + end + + table.sort(rows, function(a, b) return a.score > b.score end) + o:Section("Bordered frames", #rows) + o:Field("off-grid top", offGrid, offGrid > 0 and "BAD" or "GOOD") + o:Field("within 1px of the clip edge", nearClip, nearClip > 0 and "WARN" or "GOOD") + + -- Per-kind tally: this is the line that says whether fixing controls would + -- also fix the section boxes, or whether they are a separate problem. + local kindLine = {} + for kind, k in pairs(byKind) do + kindLine[#kindLine + 1] = ("%s %d/%d"):format(kind, k.bad, k.n) + end + table.sort(kindLine) + o:Field("by kind (bad/total)", table.concat(kindLine, " "), "NEUTRAL") + + local function emit(r) + local flag = "" + if r.bad then flag = " |cffff6060OFF-GRID|r" end + if r.clipGap and math.abs(r.clipGap) < 1.5 then flag = flag .. " |cffffaa00AT-CLIP|r" end + -- A fractional edge width is the failure a perfect 0.00 box can still have. + if r.edgeFrac and math.abs(r.edgeFrac) > 0.05 then flag = flag .. " |cffff6060SOFT-EDGE|r" end + if r.alpha and r.alpha < 0.15 then flag = flag .. " |cffffaa00FAINT|r" end + print((" [%s] %-22s top%+.2f bot%+.2f h%+.2f edge=%s a=%s clip=%s%s"):format( + r.kind:sub(1, 6), r.label:sub(1, 22), r.dTop, r.dBot, r.dH, + r.edgePx and ("%.2fpx"):format(r.edgePx) or "n/a", + r.alpha and ("%.2f"):format(r.alpha) or "n/a", + r.clipGap and ("%.1f"):format(r.clipGap) or "n/a", flag)) + end + + -- The section boxes ALWAYS get listed, however they rank -- they are the ones + -- you can actually see, and ranking buried them last time. + local boxes = 0 + for _, r in ipairs(rows) do if r.kind == "BOX" then boxes = boxes + 1 end end + if boxes > 0 then + o:Section("Section boxes", boxes .. " — the outlines around each section") + local shown = 0 + for _, r in ipairs(rows) do + if r.kind == "BOX" and shown < 10 then + emit(r) + -- Anything sitting ON this box's top border row is the prime + -- suspect now that the box's own geometry measures clean. + local over = r.frame and FindTopRowOverlaps(r.frame) or {} + if #over > 0 then + print((" |cffff6060^ COVERED BY:|r %s (box is lvl%s)") + :format(table.concat(over, ", "), tostring(r.level))) + end + shown = shown + 1 + end + end + else + o:Section("Section boxes") + o:Line("None found on this page.", "WARN") + end + + o:Section("Worst overall") + o:Line("topOff/botOff/heightOff are px from the grid; clip is px below the viewport top.", "NEUTRAL") + for i = 1, math.min(#rows, 10) do emit(rows[i]) end + print(" |cff808080Read: OFF-GRID = geometry. SOFT-EDGE = the edge is a fractional number of device px wide, so it bleeds into the next row -- a box can be a perfect 0.00 and still lose its border this way. FAINT = so little alpha that any split is invisible.|r") + print(" |cff808080Nothing is corrected at runtime any more. Every widget gets its whole-pixel numbers from its FACTORY at construction (nudging them afterwards is what made chained button rows drift), so an off-grid row after a scale change is expected and is not a bug. What still matters here is SOFT-EDGE and FAINT.|r") + + -- Persist, for the same reason gapcheck does: transcribing a screenful of + -- numbers out of the chat frame is not practical remotely, and every reading + -- of this symptom that came from eyeballing rather than the file has been + -- wrong. Same session stamp, so a capture never mixes two builds. + DandersFramesDebugDB = DandersFramesDebugDB or {} + if DandersFramesDebugDB.pixelSession ~= GAP_SESSION then + DandersFramesDebugDB.pixelcheck = nil + DandersFramesDebugDB.pixelSession = GAP_SESSION + end + DandersFramesDebugDB.pixelcheck = DandersFramesDebugDB.pixelcheck or {} + local pdump = { + when = date("%Y-%m-%d %H:%M:%S"), + ppu = ppu, scroll = scroll, + viewTop = dPageTop, viewBot = dPageBot, viewH = dPageH, + rows = {}, + } + for i, r in ipairs(rows) do + pdump.rows[i] = { + label = tostring(r.label):sub(1, 40), kind = r.kind, + dTop = r.dTop, dBot = r.dBot, dH = r.dH, + edgePx = r.edgePx, alpha = r.alpha, clipGap = r.clipGap, + level = r.level, + -- Raw geometry too: the deltas alone cannot distinguish "off-grid" + -- from "the right size but drawn somewhere unexpected". + top = r.frame and r.frame:GetTop() or nil, + bottom = r.frame and r.frame:GetBottom() or nil, + height = r.frame and r.frame:GetHeight() or nil, + width = r.frame and r.frame:GetWidth() or nil, + points = r.frame and r.frame:GetNumPoints() or nil, + } + end + -- Never overwrite an earlier run of the SAME page: the whole point of running + -- this twice is to compare a broken state against a working one, and keying + -- purely by page silently threw the first away. Numbered within the session. + local key, n = tostring(pageName), 1 + while DandersFramesDebugDB.pixelcheck[key] do + n = n + 1 + key = ("%s #%d"):format(tostring(pageName), n) + end + DandersFramesDebugDB.pixelcheck[key] = pdump + print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.pixelcheck[\"%s\"] -- |cffffcc00/reload to flush.|r") + :format(#rows, key)) +end + +-- ============================================================ +-- /df debug navprobe -- catch the left-nav hover flash in the act +-- +-- The symptom: sweeping the cursor down the nav list shows a "ghost" -- of the +-- row's text, or of the bottom part of the hover plate. It happens at moderate +-- speed, not just fast, and it survived snapping the row geometry. +-- +-- Four causes would produce that, and they are INDISTINGUISHABLE in a +-- screenshot, which is why this measures instead of reasoning: +-- +-- (a) two rows lit at once -- the previous row's OnLeave never ran, so two +-- plates are visible together for a frame or two; +-- (b) focus thrash -- the cursor sits still over one row but mouse focus +-- alternates between it and something else (the scroll frame, a sibling, +-- an overlapping rect), so the plate flickers on and off in place; +-- (c) geometry -- rows overlap, or leave a dead band between them where +-- NOTHING is lit, so crossing it reads as the plate breaking up; +-- (d) none of the above -- a pure rendering artefact, in which case the trace +-- shows exactly one clean enter/leave per row and the answer is elsewhere. +-- +-- Static geometry first (overlaps and dead bands are visible without moving the +-- mouse), then a live trace of every change in focus and in each row's plate +-- alpha, stamped with the frame number. (a) and (b) show up as repeated +-- transitions within a single crossing; (c) as a run of frames with nothing lit. +function GUI.NavProbe(seconds) + local container = GUI.tabContainer + if not (container and container:IsVisible()) then + DF:Say("the settings window is not open.") + return + end + local ppu = PixelsPerUnit(container) or 1 + + -- Rows in LAYOUT order (top to bottom), which is what makes the neighbour + -- comparison below meaningful -- GetChildren order is creation order. + local rows = {} + for _, catName in ipairs(GUI.CategoryOrder or {}) do + local cat = GUI.Categories and GUI.Categories[catName] + if cat and cat:IsShown() then + rows[#rows + 1] = { f = cat, label = "[" .. tostring(catName) .. "]" } + for _, btn in ipairs(cat.children or {}) do + if btn:IsShown() then + rows[#rows + 1] = { f = btn, label = DescribeFrame(btn) } + end + end + end + end + + local o = DF:Out("Nav Probe") + o:Section("Rows") + o:Field("visible", #rows, #rows > 0 and "GOOD" or "WARN") + o:Field("px per unit", ("%.4f"):format(ppu), "NEUTRAL") + + -- The ANCESTOR CHAIN, because a row cannot be on the grid if the frame it + -- hangs off is not: every ancestor here is two-corner anchored, so nothing + -- corrects them after the fact and a fraction anywhere propagates to all 42 + -- rows identically. If the rows read a uniform offset, this line says which + -- link introduced it -- that is how the 4-unit nav inset (0.375px) was found + -- after the rows themselves measured clean on height. + local chain, node = {}, container + while node and #chain < 6 do + local t = node:GetTop() + chain[#chain + 1] = ("%s top%+.2f"):format( + (node.GetObjectType and node:GetObjectType() or "?"):sub(1, 6), + PixelOffsetOf(t, PixelsPerUnit(node) or ppu) or 0) + node = node:GetParent() + end + print(" chain (row -> window): " .. table.concat(chain, " | ")) + + -- Geometry: the gap to the row above, in DEVICE pixels. Negative = the rows + -- overlap (both can claim the cursor); more than ~1px positive = a dead band + -- with no row under the cursor at all. Either one produces a visible break. + local overlaps, bands = 0, 0 + for i, r in ipairs(rows) do + local f = r.f + local top, bot, h = f:GetTop(), f:GetBottom(), f:GetHeight() + local dTop = PixelOffsetOf(top, ppu) or 0 + local dH = PixelOffsetOf(h, ppu) or 0 + local gap + if i > 1 then + local prevBot = rows[i - 1].f:GetBottom() + if prevBot and top then gap = (prevBot - top) * ppu end + end + local flag = "" + if gap and gap < -0.05 then flag = " |cffff6060OVERLAPS ABOVE|r"; overlaps = overlaps + 1 + elseif gap and gap > 1.05 then flag = " |cffffaa00DEAD BAND|r"; bands = bands + 1 end + if math.abs(dTop) > 0.05 or math.abs(dH) > 0.05 then + flag = flag .. " |cffffaa00OFF-GRID|r" + end + print((" %-24s top%+.2f h%+.2f gap=%s lvl%d%s"):format( + r.label:sub(1, 24), dTop, dH, + gap and ("%.2fpx"):format(gap) or "n/a", + f:GetFrameLevel() or 0, flag)) + end + print((" %d overlapping rows, %d dead bands between rows"):format(overlaps, bands)) + + -- Live trace. + S.navTrace = S.navTrace or CreateFrame("Frame") + S.navTrace:SetScript("OnUpdate", nil) + local dur = tonumber(seconds) or 8 + local elapsed, frames, events = 0, 0, 0 + local lastLit, lastFocus = nil, nil + print((" |cff00ff00tracing for %ds|r -- sweep the cursor across the nav list now."):format(dur)) + + S.navTrace:SetScript("OnUpdate", function(_, dt) + elapsed = elapsed + dt + frames = frames + 1 + + -- Which row the shared plate is parked on. There is only one plate now, so + -- "TWO LIT" is structurally impossible -- that is the point of the change, + -- and this still checks for it in case the plate is ever reintroduced + -- per-row. No parentheses around the colour call: they would truncate the + -- multi-return to one value and alpha would read nil every frame -- the + -- exact mistake that cost three rounds on the border bug. + local lit = {} + local hl = GUI.navHover + if hl and hl:IsShown() and hl.owner then + local _, _, _, a = hl:GetBackdropColor() + if a and a > 0.01 then + for _, r in ipairs(rows) do + if r.f == hl.owner then lit[#lit + 1] = r.label:sub(1, 18) break end + end + end + end + local litKey = table.concat(lit, "+") + + -- What actually owns the mouse. If this is NOT the lit row, the plate and + -- the focus disagree, which is cause (b). + local focus = "-" + local foci = GetMouseFoci and GetMouseFoci() + local top = (foci and foci[1]) or (GetMouseFocus and GetMouseFocus()) + if top then + for _, r in ipairs(rows) do if r.f == top then focus = r.label:sub(1, 18) break end end + if focus == "-" then + focus = "<" .. ((top.GetObjectType and top:GetObjectType()) or "?") .. ">" + end + end + + if litKey ~= lastLit or focus ~= lastFocus then + events = events + 1 + if events <= 120 then + print((" f%-5d t=%.3f lit=%-24s focus=%s%s"):format( + frames, elapsed, + (litKey ~= "" and litKey or "(none)"), + focus, + (#lit > 1) and " |cffff6060TWO LIT|r" + or ((litKey == "" and focus ~= "-") and " |cffffaa00FOCUS BUT UNLIT|r" or ""))) + end + lastLit, lastFocus = litKey, focus + end + + if elapsed >= dur then + S.navTrace:SetScript("OnUpdate", nil) + print((" |cff00ff00navprobe done|r -- %d frames, %d state changes%s"):format( + frames, events, events > 120 and " (first 120 shown)" or "")) + print(" |cff808080Read: one enter + one leave per row = clean, look elsewhere. Repeated flips inside one crossing = focus thrash. TWO LIT = a stale plate. lit=(none) with focus on a row = the handler did not fire.|r") + end + end) +end + +-- ============================================================ +-- /df debug gapcheck -- measure the vertical rhythm, don't eyeball it +-- +-- The question this answers: "which rows are too far apart, and which are too +-- close?" It cannot be answered from GUI.RowHeight alone, because those numbers +-- are SLOT heights, not gaps. LayoutChildren stacks rows flush (y = y - height), +-- so the visible gap between two rows is: +-- +-- (slot height - content bottom) of the row above +-- + (slot top - content top) of the row below +-- +-- A row whose content is short inside a tall slot gets a big gap and nothing +-- flags it. The slider is the worst case by construction: it shares the 55 slot +-- with the dropdown and edit box, whose openers reach ~40, while a slider only +-- draws to ~32 (label, an 8px track, a 20px value box) -- so it carries ~23px of +-- slack against their ~15. +-- +-- Measured, not derived, for a reason: labels WRAP, override markers hang off +-- rows, banners re-measure themselves after construction, and hideOn rows drop +-- out. Only the live rects know the real content extent, which is exactly the +-- lesson from the border bug -- the arithmetic looked right there too. +-- +-- Rows are compared only against their SIBLINGS IN THE SAME GROUP. That is where +-- the rhythm actually reads, and it sidesteps having to reconstruct which column +-- a widget landed in. +local function ContentExtent(f) + local top, bottom + local function acc(o) + if not o or not o.IsShown or not o:IsShown() then return end + -- Skip things that draw NOTHING: an empty label or a fully transparent + -- placeholder still has a rect, and counting it would inflate the content + -- and hide the very slack we are looking for. + -- + -- FontStrings ONLY. An EditBox also answers GetText, and an empty one + -- would have been skipped here even though its box is plainly drawn -- + -- which would have under-measured every blank input on the page. + if o.GetObjectType and o:GetObjectType() == "FontString" then + local s = o:GetText() + if s == nil or s == "" then return end + end + if o.GetAlpha and (o:GetAlpha() or 1) <= 0.01 then return end + local t, b = o:GetTop(), o:GetBottom() + if t and b then + top = (top and math.max(top, t)) or t + bottom = (bottom and math.min(bottom, b)) or b + end + end + if f.GetRegions then for _, r in ipairs({ f:GetRegions() }) do acc(r) end end + if f.GetChildren then for _, c in ipairs({ f:GetChildren() }) do acc(c) end end + return top, bottom +end + +-- Every SHOWN SettingsGroup under the page, at any depth (the Aura Designer nests +-- its groups inside cards). +local function CollectGroups(root, out, depth) + if not root or (depth or 0) > 8 then return out end + if root.groupChildren and root.IsShown and root:IsShown() then out[#out + 1] = root end + if root.GetChildren then + for _, c in ipairs({ root:GetChildren() }) do + if c.IsShown and c:IsShown() then CollectGroups(c, out, (depth or 0) + 1) end + end + end + return out +end + +function GUI.GapCheck(mode) + if mode == "clear" then + DandersFramesDebugDB = DandersFramesDebugDB or {} + DandersFramesDebugDB.gapcheck = nil + DF:Say("saved capture cleared (/reload to flush).") + return + end + + local page, pageName + for name, p in pairs(GUI.Pages or {}) do + if p.IsShown and p:IsShown() then page, pageName = p, name break end + end + if not page then + DF:Say("Gap check", "no settings page is open", "WARN") + return + end + + local groups = CollectGroups(page.child or page, {}, 0) + local rows, byKind, pairs_, nRows = {}, {}, {}, 0 + + for _, group in ipairs(groups) do + local prev + for _, entry in ipairs(group.groupChildren or {}) do + local w = entry.widget + if w and w.IsShown and w:IsShown() and entry.height then + local slotTop = w:GetTop() + local cTop, cBot = ContentExtent(w) + if slotTop and cTop and cBot then + -- The slot is entry.height from the widget's TOP -- NOT the + -- widget's own rect. LayoutChildren only sets TOPLEFT (and + -- width), so a container constructed at 50 sitting in a 55 + -- slot would under-report by 5 if we used GetBottom(). + local slotBot = slotTop - entry.height + local kind = w.rowKind or (w.LayoutChildren and "group") + or (w.GetObjectType and w:GetObjectType()) or "?" + local r = { + kind = kind, + label = (w.GetText and w:GetText()) or (w.Text and w.Text.GetText and w.Text:GetText()) or kind, + slot = entry.height, + content = cTop - cBot, + padTop = slotTop - cTop, + padBot = cBot - slotBot, + -- Absolute edges, because the gap has to be measured from + -- where the rows LANDED, not from entry.height. The layout + -- can shorten a row after the fact (the compact-run + -- tightening does exactly that), and slot arithmetic + -- cannot see it -- the first version of this reported the + -- untightened number and made the feature look inert. + cTop = cTop, + cBot = cBot, + -- The slot's own top, so the height the layout ACTUALLY + -- used is derivable offline (prev.slotTop - this.slotTop) + -- and can be compared against entry.height. + slotTop = slotTop, + tight = w._rowTightened or false, + nextKind = w._rowNextKind, + } + nRows = nRows + 1 + rows[#rows + 1] = r + + local k = byKind[kind] + if not k then k = { n = 0, slot = 0, content = 0, padTop = 0, padBot = 0 } byKind[kind] = k end + k.n, k.slot, k.content = k.n + 1, k.slot + r.slot, k.content + r.content + k.padTop, k.padBot = k.padTop + r.padTop, k.padBot + r.padBot + + -- The gap the eye actually sees: the distance between where + -- the previous row's content ENDED and this one's STARTS, + -- straight off the resolved rects. + if prev then + local gap = prev.cBot - r.cTop + local key = ("%s -> %s"):format(prev.kind, kind) + local p = pairs_[key] + if not p then p = { n = 0, sum = 0, min = gap, max = gap } pairs_[key] = p end + p.n, p.sum = p.n + 1, p.sum + gap + p.min, p.max = math.min(p.min, gap), math.max(p.max, gap) + end + prev = r + end + end + end + end + + if nRows == 0 then + local o = DF:Out("Gap Check", "page " .. tostring(pageName)) + o:Line("No measurable rows — is everything collapsed?", "WARN") + o:Siblings("gapcheck") + return + end + + local o = DF:Out("Gap Check", "page " .. tostring(pageName)) + o:Section("Measured", "UI units") + o:Field("groups", #groups, "NEUTRAL") + o:Field("rows", nRows, "NEUTRAL") + + o:Section("Per kind") + o:Line("slot is what RowHeight hands out; content is what it actually draws.", "NEUTRAL") + local kinds = {} + for kind in pairs(byKind) do kinds[#kinds + 1] = kind end + table.sort(kinds, function(a, b) return (byKind[a].padBot / byKind[a].n) > (byKind[b].padBot / byKind[b].n) end) + for _, kind in ipairs(kinds) do + local k = byKind[kind] + print((" %-12s n=%-3d slot %5.1f content %5.1f padTop %4.1f |cffffcc00padBottom %4.1f|r") + :format(kind, k.n, k.slot / k.n, k.content / k.n, k.padTop / k.n, k.padBot / k.n)) + end + + -- Did the compact-run tightening actually fire? The gap alone cannot say -- + -- it only shows the result -- and reading the source said it should while the + -- measurement said it had not. So count the decision directly, and when a run + -- did NOT close up, name the kind that broke it. + local tightened, compactRows, breakers = 0, 0, {} + for _, r in ipairs(rows) do + if GUI.RowCompact[r.kind] then + compactRows = compactRows + 1 + if r.tight then + tightened = tightened + 1 + elseif r.nextKind then + breakers[r.nextKind] = (breakers[r.nextKind] or 0) + 1 + end + end + end + if compactRows > 0 then + local why = {} + for k, n in pairs(breakers) do why[#why + 1] = ("%s x%d"):format(k, n) end + table.sort(why) + print((" compact-run tightening: |cffffcc00%d/%d|r compact rows closed up%s") + :format(tightened, compactRows, + #why > 0 and (" |cff808080(run broken by: %s)|r"):format(table.concat(why, ", ")) or "")) + end + + print(" gaps between stacked rows (padBottom above + padTop below) -- widest first:") + local keys = {} + for key in pairs(pairs_) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return (pairs_[a].sum / pairs_[a].n) > (pairs_[b].sum / pairs_[b].n) end) + for _, key in ipairs(keys) do + local p = pairs_[key] + local avg = p.sum / p.n + -- A pair whose min and max differ is NOT a spacing constant -- something + -- (a wrapped label, a hand-rolled AddSpace) is varying it, and averaging + -- would hide exactly that. + local spread = (p.max - p.min > 0.5) + and (" |cffff6060varies %.1f..%.1f|r"):format(p.min, p.max) or "" + print((" %6.1f %-26s x%d%s"):format(avg, key, p.n, spread)) + end + + if mode == "all" then + print(" every row, in layout order:") + for _, r in ipairs(rows) do + print((" %-12s %-22s slot %5.1f content %5.1f padTop %4.1f padBottom %4.1f") + :format(r.kind, tostring(r.label):sub(1, 22), r.slot, r.content, r.padTop, r.padBot)) + end + end + + -- Persist the RAW rows to SavedVariables. Reading a hundred rows out of the + -- chat frame is not practical, and transcribing them by hand would introduce + -- exactly the kind of error this probe exists to avoid. + -- + -- Keyed BY PAGE so visiting several pages accumulates one dataset instead of + -- each run overwriting the last -- walk the pages you care about, then + -- /reload ONCE. SavedVariables are only flushed on logout or reload, so a run + -- that is never followed by one is never written. + -- + -- Its own saved variable, not a corner of DandersFramesDB_v2: diagnostics + -- must not sit inside the profile DB, where they would ride along with every + -- export and show up in the export audit. + DandersFramesDebugDB = DandersFramesDebugDB or {} + DandersFramesDebugDB.gapcheck = DandersFramesDebugDB.gapcheck or {} + -- Drop everything from a PREVIOUS session first. Accumulating by page is what + -- makes a multi-page capture possible, but it also means a page you did not + -- revisit after a code change keeps its stale rows -- and mixing two builds + -- invents variance that is not in either of them. (It already cost one wrong + -- diagnosis: three stale pages made the row heights look like they had not + -- applied at all.) One /reload = one dataset. + if DandersFramesDebugDB.gapSession ~= GAP_SESSION then + wipe(DandersFramesDebugDB.gapcheck) + DandersFramesDebugDB.gapSession = GAP_SESSION + end + local dump = { when = date("%Y-%m-%d %H:%M:%S"), rows = {} } + if page.GetEffectiveScale then dump.scale = page:GetEffectiveScale() end + for i, r in ipairs(rows) do + dump.rows[i] = { + kind = r.kind, label = tostring(r.label):sub(1, 40), + slot = r.slot, content = r.content, + padTop = r.padTop, padBot = r.padBot, + cTop = r.cTop, cBot = r.cBot, -- so gaps can be re-derived offline + slotTop = r.slotTop, tight = r.tight, nextKind = r.nextKind, + } + end + DandersFramesDebugDB.gapcheck[tostring(pageName)] = dump + + local nPages = 0 + for _ in pairs(DandersFramesDebugDB.gapcheck) do nPages = nPages + 1 end + print((" |cff00ff00saved|r %d rows to DandersFramesDebugDB.gapcheck[\"%s\"] -- %d page(s) captured. |cffffcc00Visit the pages you care about, then /reload to flush.|r") + :format(#rows, tostring(pageName), nPages)) + print(" |cff808080Read: padBottom is the slack under a row -- the knob is GUI.RowHeight[kind], and slot - content IS that slack. A kind sorting to the top of the first list is over-spaced; one near zero is cramped. In the second list, 'varies' means the gap is not coming from RowHeight alone. Add 'all' for every row, 'clear' to wipe the saved capture.|r") +end +-- (Removed) GUI.GapCheckAll — a no-arg alias for GapCheck("all"), superseded once the +-- dispatcher started forwarding the mode argument. Zero callers. + +-- Every frame that carries a 1px backdrop edge, so the sweep below can find them +-- without any page needing to know what it contains. Weak keys: a retired page's +-- widgets are reparented to the trash frame rather than destroyed, and this must +-- not be what keeps them reachable. + +-- Re-derive the BORDER THICKNESS of everything currently on screen. Since the +-- geometry correction was removed this no longer moves or resizes anything, so +-- the only thing it can change is how many device pixels an edge is drawn at -- +-- which only matters when the SCALE changes. That is why its callers are the +-- scale slider and the window drag/resize handlers, not anything per-frame. +-- +-- IsVisible (not IsShown) keeps it cheap: a widget on a page that is not open +-- has a hidden ancestor and is skipped, so the work stays proportional to what +-- is displayed rather than to everything the registry has accumulated. Safe to +-- call repeatedly -- it writes only when the computed edge width actually +-- differs, so a second call in the same state does nothing at all. + +-- ============================================================ +-- PIXEL BORDER -- how every outlined GUI surface draws its edge. +-- +-- Four textures we own, instead of a backdrop's edgeFile, because a backdrop +-- edge is drawn one UI unit wide and we cannot control how that lands on the +-- physical pixel grid. At the scales people play at, one unit is about one +-- device pixel, and a one-device-pixel line has exactly two states: [1.0, 0] +-- when it lands on the grid and [0.5, 0.5] when it does not. Identical ink, +-- completely different appearance -- so an edge that crosses the grid while the +-- page scrolls appears to flicker, and at the low alphas this GUI uses, both +-- halves of the split can fall under the visibility floor and the border simply +-- is not there. +-- +-- The long way round to this was trying to place that 1px line perfectly. It +-- cannot be done. Lua-side snapping runs at discrete moments -- build, +-- scroll-settle, show -- but the line crosses the grid on EVERY frame of a +-- scroll: correcting at moment N does nothing for the next thirty frames, and +-- correcting mid-motion adds a visible jump on top of the softness. Handing it +-- to the renderer (SetSnapToPixelGrid) is worse still: it rounds the two edges +-- of a thin texture independently, so a 1px line can round to zero height and +-- vanish outright. +-- +-- The answer is to stop placing a thin line accurately and draw one that does +-- not care where it lands. See PX_BORDER_THICKNESS below. +local pixelBordered = setmetatable({}, { __mode = "k" }) +local PX_SIDES = { "top", "bottom", "left", "right" } + +-- THE dial. Border thickness in DEVICE PIXELS, before any per-surface weight. +-- +-- The floor is what matters, not the exact value: anything above 1 always covers +-- at least one FULL pixel row plus a partial, so the line can never vanish and +-- never changes its total ink as the content moves. Only 1.0 has the two-state +-- failure ([1.0, 0] when it lands on the grid, [0.5, 0.5] when it does not -- +-- identical ink, completely different appearance), which is the flicker that +-- started this. +-- +-- Back to 2, and the alpha carries the weight instead. THICKNESS and WEIGHT are +-- separate dials and conflating them was the mistake: +-- +-- * thickness decides how UNIFORM the line looks. At 1.5 the rows come out +-- [0.25, 1.0, 0.25] or [0.5, 1.0] depending on where the box lands, so +-- neighbouring boxes render visibly different widths -- Krathe's "one +-- thinner, one thicker". The bigger the base, the smaller that proportion, +-- so 2 is noticeably more even than 1.5. +-- * alpha decides how HEAVY it looks, and does not vary with position at all. +-- +-- So: hold thickness at the value that renders evenly, and take the weight out +-- of the alpha. 2px at 0.7 alpha is about the same total ink as 1.5px at full, +-- but without the width wobble. +-- +-- The floor still matters if this is ever tuned down: anything above 1 always +-- covers a full row plus a partial, so it cannot vanish. Only 1.0 has the +-- two-state failure -- [1.0, 0] on the grid, [0.5, 0.5] off it, identical ink +-- and completely different appearance -- which was the original flicker. +local PX_BORDER_THICKNESS = 2 + +-- Applied to every pixel border's alpha. The authored colours were all chosen +-- for a 1px hairline; drawing them at 2px would read heavier than intended, and +-- this puts that correction in ONE place rather than re-tuning six call sites +-- (and whatever opts in later) by hand. +local PX_BORDER_ALPHA = 0.7 + +-- Thickness and anchors, re-derived whenever the scale changes. +-- frame._pxWeight is the caller's edgeSize (1 = the standard hairline), so a +-- surface that asked for a heavier outline keeps its relative weight. +local function LayoutPixelBorder(frame) + local b = frame._pxBorder + if not b then return end + local ppu = PixelsPerUnit(frame) + if not ppu then return end + -- TWO device pixels, not one, and this is the whole finding. + -- + -- A 1px line cannot be drawn reliably at a fractional offset. With vertex + -- snapping ON, the top and bottom of a 1px-tall texture can round to the SAME + -- pixel row -- height zero, line GONE. That is what the red diagnostic showed: + -- verticals solid (X is stable), horizontals absent at certain scroll + -- positions (Y carries the scroll's fractional phase). The old backdrop edge + -- did it too, so this is not specific to either mechanism. + -- + -- With snapping OFF at 2px the rows come out [partial, full, partial] -- + -- roughly 0.5 / 1.0 / 0.5 -- for ANY offset. The total ink is constant and + -- there is always at least one fully-lit row, so the line can neither vanish + -- nor visibly change weight as the page scrolls. A 1px line has only the two + -- states [1.0, 0] and [0.5, 0.5]: same ink, completely different appearance, + -- which IS the flicker. + -- + -- So: stop trying to place a 1px line perfectly, and draw one that does not + -- care where it lands. + local devicePx = PX_BORDER_THICKNESS * (frame._pxWeight or 1) + local px = devicePx / ppu + frame._pxDevicePx = devicePx -- what /df debug pixelcheck reports for this surface + frame._pxPpu = ppu -- the scale this thickness was derived at + + b.top:ClearAllPoints() + b.top:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + b.top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + b.top:SetHeight(px) + + b.bottom:ClearAllPoints() + b.bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + b.bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + b.bottom:SetHeight(px) + + b.left:ClearAllPoints() + b.left:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + b.left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + b.left:SetWidth(px) + + b.right:ClearAllPoints() + b.right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + b.right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + b.right:SetWidth(px) +end + +-- THE SHIM, and the reason buttons could not adopt this until now. +-- +-- A pixel border has no backdrop edgeFile, so SetBackdropBorderColor -- which is +-- how ~150 call sites across the addon drive hover, active and disabled states +-- -- would silently do nothing. Converting those by hand meant finding all of +-- them, and missing one meant a button whose hover just stops working, with +-- nothing to see in a log. +-- +-- So the frame gets its own method that shadows the mixin's. Every existing +-- caller keeps working unchanged, and there is no list to be exhaustive about. +-- Assigning onto the frame table is enough: SetBackdropBorderColor is not a +-- native widget method, it arrives via BackdropTemplateMixin, so it is already +-- a plain table entry and ours simply takes its place. +local function SetPixelBorderColor(self, r, g, b, a) + local bd = self._pxBorder + if not bd then return end + a = a or 1 + -- Mutated, not replaced. This is the hover path for every button, dropdown + -- and swatch in the GUI -- OnEnter and OnLeave both land here -- so a fresh + -- table per call would be pure garbage. + local c = self._pxColor + if c then c[1], c[2], c[3], c[4] = r, g, b, a + else self._pxColor = { r, g, b, a } end + -- Alpha scaled once, here, so the authored colours stay the values a reader + -- would expect to see (8%, 50%, 80%) rather than pre-compensated numbers. + local drawn = a * PX_BORDER_ALPHA + for _, side in ipairs(PX_SIDES) do + bd[side]:SetColorTexture(r, g, b, drawn) + end +end + +-- Returns the AUTHORED colour, not the drawn one. PX_BORDER_ALPHA is a +-- rendering correction, so a caller that reads a colour back and writes it again +-- must not end up with it applied twice. +local function GetPixelBorderColor(self) + local c = self._pxColor + if not c then return end + return c[1], c[2], c[3], c[4] +end + +-- Re-derive on show, so a surface built at one UI scale and opened after a scale +-- change comes up at the right thickness. Self-registering on purpose: this +-- used to be an explicit call that six floating windows each had to remember to +-- make, and a bordered frame maintaining its own border cannot be forgotten. +-- +-- Guarded on the scale it was last laid out at, because this fires for every +-- widget on every page open. In the ordinary case -- nothing has changed since +-- the widget was built -- it costs one float compare and returns. +local function RelayoutPixelBorderOnShow(self) + if PixelsPerUnit(self) ~= self._pxPpu then LayoutPixelBorder(self) end +end + +-- color = {r, g, b, a}; weight mirrors backdrop edgeSize (1 = standard hairline) +function GUI:ApplyPixelBorder(frame, color, weight) + frame._pxWeight = weight or frame._pxWeight or 1 + local b = frame._pxBorder + if not b then + b = {} + for _, side in ipairs(PX_SIDES) do + -- ARTWORK at the top sublevel, not BORDER. The colour picker is + -- what forces it up: its hue square lays a full-size ARTWORK + -- gradient over the frame, which would cover a BORDER-layer edge + -- completely -- and that is a trap, because the border would + -- disappear for a reason nothing about the border explains. + -- + -- Not OVERLAY, though. Every label, icon, check mark, grip and + -- slider thumb in this GUI is OVERLAY, and those belong above the + -- edge they sit inside. Sublevel 7 of ARTWORK clears the interior + -- fills and gradients and nothing else. + local tex = frame:CreateTexture(nil, "ARTWORK", nil, 7) + -- Snapping OFF, deliberately, and this is a reversal of what this + -- started out as. Vertex snapping is what COLLAPSES a thin line: + -- rounding a 2px texture's edges independently gives a height of 1, + -- 2 or 3 px depending on where it lands, so the border would + -- visibly change weight as you scroll. Left un-snapped it is always + -- exactly 2px of ink, wherever it falls -- constant, which is what + -- the eye actually wants. Crispness was never the goal; STABILITY + -- was. + if tex.SetSnapToPixelGrid then tex:SetSnapToPixelGrid(false) end + -- No half-texel offset, so the 2px spans the rows we asked for. + if tex.SetTexelSnappingBias then tex:SetTexelSnappingBias(0) end + b[side] = tex + end + frame._pxBorder = b + pixelBordered[frame] = true + if frame.HookScript then + frame:HookScript("OnShow", RelayoutPixelBorderOnShow) + end + end + -- Outside the create block on purpose: the textures are kept when a surface + -- is re-issued, so keying the shim off their creation would leave a + -- re-adopted frame with the real methods still in place and its recolours + -- going nowhere. Assigning every time is idempotent and cannot get this + -- wrong. + frame.SetPixelBorderColor = SetPixelBorderColor + frame.SetBackdropBorderColor = SetPixelBorderColor + frame.GetBackdropBorderColor = GetPixelBorderColor + local c = color or { 1, 1, 1, 0.08 } + SetPixelBorderColor(frame, c[1], c[2], c[3], c[4] or 1) + -- Re-shown because a surface can be re-issued as fill-only and back again + -- (FlashWidget does this on every pulse). + for _, side in ipairs(PX_SIDES) do b[side]:Show() end + LayoutPixelBorder(frame) + return frame +end + +-- Hand the frame its own methods back, and take the textures down. Needed by the +-- one path that leaves the pixel border behind: a surface re-issued with +-- opts.backdropEdge after it had one. Nothing does that today, but if the shim +-- were left installed the backdrop recolour that follows would go nowhere at +-- all -- a hover that silently stops working, which is precisely the failure +-- this system exists to make impossible. +local function RevertPixelBorder(frame) + if not frame or not frame._pxBorder then return end + GUI:HidePixelBorder(frame) + frame.SetPixelBorderColor = nil + frame.SetBackdropBorderColor = BackdropTemplateMixin.SetBackdropBorderColor + frame.GetBackdropBorderColor = BackdropTemplateMixin.GetBackdropBorderColor +end + +-- Take the border down without discarding it. The textures are ours, so unlike a +-- backdrop edge nothing else will remove them when the surface is re-issued +-- without an outline. +function GUI:HidePixelBorder(frame) + local b = frame and frame._pxBorder + if not b then return end + for _, side in ipairs(PX_SIDES) do b[side]:Hide() end +end + + +-- THE element backdrop. Every bordered/filled GUI surface goes through here, so +-- a change to the look lands everywhere at once. Three shapes, one code path: +-- +-- (default) fill + 1px outline -- dropdowns, edit boxes, buttons +-- opts.fill=false outline only -- for a surface whose interior is drawn by +-- something else (the colour picker's hue/alpha gradients +-- and checkerboards), where a fill would paint over it +-- opts.outline=false fill only -- flat chips, segment buttons, label plates +-- +-- opts.bgColor / opts.borderColor take {r,g,b[,a]} or {[1],[2],[3][,4]} and +-- override the C_ELEMENT / C_BORDER defaults. opts.edgeSize thickens the outline +-- (the popup and wizard chrome use 2). opts.inset (a +-- single number, applied to all four sides) pulls the fill in from the edge so it +-- does not underlap a translucent border -- default 0, i.e. the fill runs to the +-- frame edge. opts.backdropEdge draws the outline the old way, as a backdrop +-- edgeFile; see below for why nothing should want that. +local function CreateElementBackdrop(frame, opts) + opts = opts or {} + local fill, outline = opts.fill ~= false, opts.outline ~= false + -- The outline is drawn as our own textures, not a backdrop edgeFile. See + -- ApplyPixelBorder -- a 1px backdrop edge cannot be drawn reliably at a + -- fractional offset, which is the whole missing-border story. + -- + -- This was an opt-in while the hover states were still a problem: a pixel + -- border is not repainted by SetBackdropBorderColor, so converting buttons + -- blind would have silently killed every hover in the GUI. The shim in + -- ApplyPixelBorder settles that -- the frame gets its own + -- SetBackdropBorderColor -- so it is now the default for everything that + -- comes through here, and opts.backdropEdge is the escape hatch for a + -- surface that genuinely needs the old edgeFile. + local usePixel = outline and not opts.backdropEdge + if not frame.SetBackdrop then Mixin(frame, BackdropTemplateMixin) end + local inset = opts.inset + frame:SetBackdrop({ + bgFile = fill and "Interface\\Buttons\\WHITE8x8" or nil, + edgeFile = (outline and not usePixel) and "Interface\\Buttons\\WHITE8x8" or nil, + edgeSize = (outline and not usePixel) and (opts.edgeSize or 1) or nil, + insets = inset and { left = inset, right = inset, + top = inset, bottom = inset } or nil, + }) + if fill then + local c = opts.bgColor + if c then frame:SetBackdropColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) + else frame:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, C_ELEMENT.a) end + end + if outline then + local c = opts.borderColor + if usePixel then + GUI:ApplyPixelBorder(frame, + c and { c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1 } + or { C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5 }, + opts.edgeSize) + else + RevertPixelBorder(frame) + if c then + frame:SetBackdropBorderColor(c.r or c[1], c.g or c[2], c.b or c[3], c.a or c[4] or 1) + else + frame:SetBackdropBorderColor(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + end + end + else + -- A backdrop edge disappears on its own when the backdrop is re-issued + -- without one; our textures do not, so an outlined surface re-issued as + -- fill-only has to be told. FlashWidget does exactly this: it re-runs + -- this factory on every pulse, with outline true or false depending on + -- how the caller wants that pulse to read. + GUI:HidePixelBorder(frame) + end + return frame +end + +-- The window chrome: the settings window itself, the changelog overlay and the +-- dropdown menu panel. Distinct from the public GUI:CreatePanelBackdrop further +-- down (dialogs and floating panels, colour-configurable) -- this one is always +-- the dark background behind a hard black edge. +-- +-- Routed through the factory rather than issuing its own backdrop, which is what +-- it used to do. It was the last thing in the GUI still drawing a border as an +-- edgeFile, and a black 1px line is the case where that reads least badly -- it +-- has enough contrast that a split across two device rows softens it instead of +-- losing it. Still worth converting: softening on every scroll is what the whole +-- pixel border exists to stop, and while this was the last holdout the entire +-- snap-registry existed to serve three call sites. +local function CreatePanelBackdrop(frame) + return CreateElementBackdrop(frame, { + bgColor = C_BACKGROUND, + borderColor = { 0, 0, 0, 1 }, + }) +end +-- ☠ These two MUST be published here, on the private table, not aliased from +-- GUI.CreateElementBackdrop / GUI.CreatePanelBackdrop. Those public names are +-- METHOD WRAPPERS (`function GUI:X(...) return X(...) end`) declared later in +-- GUI/Widgets.lua -- a different object that happens to share the name. A +-- sibling part aliasing the wrapper gets nil at load time, and if it did not, +-- the wrapper would end up calling itself. +P.CreateElementBackdrop = CreateElementBackdrop +P.CreatePanelBackdrop = CreatePanelBackdrop + +-- Style a ScrollFrameTemplate scrollbar to use the pill-shaped thumb +-- All scroll frames must use ScrollFrameTemplate (not UIPanelScrollFrameTemplate) +-- +-- This used to also quantise the scroll offset to whole device pixels, on the +-- reasoning that Blizzard's scrollbar drives the offset as a fraction of the +-- range and so parks the content on an arbitrary sub-pixel row. That was true, +-- and it did not help: a thin border is soft at SOME offsets no matter which +-- offsets you allow, and quantising only changed which ones. The border being +-- two device pixels wide is what made the question stop mattering -- it draws +-- the same amount of ink wherever it lands. Do not add it back. +local function StyleScrollBar(scrollFrame) + local sb = scrollFrame.ScrollBar + if not sb then return end + + -- Hide track background and track end caps + if sb.Background then sb.Background:Hide() end + if sb.Track then + if sb.Track.Begin then sb.Track.Begin:Hide() end + if sb.Track.End then sb.Track.End:Hide() end + if sb.Track.Middle then sb.Track.Middle:Hide() end + end + + -- Style the pill-shaped thumb — hide default textures, overlay with themed color + if sb.Thumb then + if sb.Thumb.Begin then sb.Thumb.Begin:Hide() end + if sb.Thumb.End then sb.Thumb.End:Hide() end + if sb.Thumb.Middle then sb.Thumb.Middle:Hide() end + if not sb.Thumb.customBg then + local thumb = sb.Thumb:CreateTexture(nil, "ARTWORK") + thumb:SetAllPoints() + thumb:SetColorTexture(0.4, 0.4, 0.4, 0.8) + sb.Thumb.customBg = thumb + end + end + + -- Hide navigation buttons + if sb.Back then sb.Back:Hide() sb.Back:SetSize(1, 1) end + if sb.Forward then sb.Forward:Hide() sb.Forward:SetSize(1, 1) end + + -- Slim width + sb:SetWidth(10) +end +GUI.StyleScrollBar = StyleScrollBar + +-- ========================================================================= +-- WIDGET FACTORY +-- ========================================================================= + + + +-- ========================================================================= +-- SETTINGS GROUP - Visible container that groups related settings together +-- Ensures settings never get split across columns +-- ========================================================================= + +-- ============================================================ +-- SHARED WITH THE SETTINGS-PANEL WIDGETS +-- ============================================================ +-- DandersFrames_Options/GUI/SettingsWidgets.lua is a separate addon and cannot +-- see this file's locals. Neither is reassigned after this point, so aliasing +-- them there is safe. +P.LayoutPixelBorder = LayoutPixelBorder +P.pixelBordered = pixelBordered diff --git a/DandersFrames/GUI/Widgets.lua b/DandersFrames/GUI/Widgets.lua index b5202e62..3eb9f53f 100644 --- a/DandersFrames/GUI/Widgets.lua +++ b/DandersFrames/GUI/Widgets.lua @@ -536,60 +536,7 @@ function GUI:StyleButton(btn, opts) return btn end -function GUI:CreateButton(parent, text, width, height, func, iconName) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - local opts = { width = width or 120, height = height or 22, text = text } - -- Optional leading icon by Media\Icons name (14px to suit the small buttons). - if iconName then - opts.icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, size = 14 } - end - GUI:StyleButton(btn, opts) - btn:SetScript("OnClick", function(self) - if func then func(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - return btn -end --- Standard close/dismiss button: a small square danger-toned button showing a --- "×" glyph. Replaces the many hand-rolled red close buttons on dialogs/panels. --- opts = { size (20), onClick, tooltip, tone }. --- tone = nil → dim grey "×" at rest → white on hover (close/dismiss; default) --- tone = "danger" → RED "×" at rest → brighter red on hover (inline destructive --- removes: list-item / tag removes). Both keep the red hover wash. --- A horizontal row of buttons, chained left-to-right with one gap, sized as a --- single layout slot. Pages were building this by hand every time -- a bare --- CreateFrame, then SetPoint("LEFT", prev, "RIGHT", 6, 0) per button, plus the --- HookScript/ShowTooltip pair on any button that needed a tooltip. --- --- buttons = { { label, width, onClick, icon, tooltip, key }, ... } --- tooltip is a ShowTooltip spec ({title, lines, tone}); a button IS its own --- label, so it takes the whole-widget hover rather than AttachTooltip's --- label-only hit area (AttachTooltip returns early without a label region). --- key names the button on row.buttons for a caller that needs it later. -function GUI:CreateButtonRow(parent, buttons, opts) - opts = opts or {} - local gap, h = opts.gap or 6, opts.height or 24 - local row = CreateFrame("Frame", nil, parent) - row:SetSize(opts.width or 540, opts.rowHeight or (h + 4)) - row.buttons = {} - local prev - for i, spec in ipairs(buttons) do - local btn = GUI:CreateButton(row, spec.label, spec.width or 80, h, spec.onClick, spec.icon) - if prev then - btn:SetPoint("LEFT", prev, "RIGHT", gap, 0) - else - btn:SetPoint("LEFT", 0, 0) - end - if spec.tooltip then - btn:HookScript("OnEnter", function(self) GUI:ShowTooltip(self, spec.tooltip) end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - row.buttons[spec.key or i] = btn - prev = btn - end - return row -end function GUI:CreateCloseButton(parent, opts) opts = opts or {} @@ -838,472 +785,9 @@ local function ConfirmDeletePreset(kind, name, onDone) }) end -function GUI:CreateDesignerPresetBar(parent, opts) - opts = opts or {} - local kind = opts.kind or "aura" - local getMode = opts.getMode or function() return "party" end - local onChange = opts.onChange or function() end - - local bar = CreateFrame("Frame", nil, parent) - bar:SetHeight(24) - - local label = bar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("LEFT", 0, 0) - label:SetText(L["Template:"]) - label:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local function CurrentName() - return DF:GetModeDesignerPresetName(kind, getMode()) - end - - -- True while editing a raid auto-layout (the only context with an "inherit - -- the global preset" choice — normal party/raid modes ARE the base). - -- Mode-gated: auto-layouts are RAID-only, but the GUI can be reopened on - -- the party tab while editing (ToggleGUI re-derives SelectedMode) — the - -- PARTY preset bar must not show layout state, and its "Inherit (Global)" - -- click must never clear the RAID layout's override. - local function IsEditingLayout() - return getMode() == "raid" - and DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() - end - - -- The label to show on the dropdown button: "Inherit (Global)" when the - -- edited layout has no override, otherwise the resolved preset name. - local function CurrentLabel() - if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then - return L["Inherit (Global)"] - end - return CurrentName() - end - - -- Dropdown button + menu (rebuilt on each open so it always reflects the lib) - local ddBtn = CreateFrame("Button", nil, bar, "BackdropTemplate") - ddBtn:SetSize(150, 22) - ddBtn:SetPoint("LEFT", label, "RIGHT", 6, 0) - CreateElementBackdrop(ddBtn) - ddBtn.text = ddBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - ddBtn.text:SetPoint("LEFT", 6, 0) - ddBtn.text:SetPoint("RIGHT", -16, 0) - ddBtn.text:SetJustifyH("LEFT") - ddBtn.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - local arrow = ddBtn:CreateTexture(nil, "OVERLAY") - arrow:SetPoint("RIGHT", -4, 0) - arrow:SetSize(10, 10) - arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") - arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - - local menu = CreateFrame("Frame", nil, ddBtn, "BackdropTemplate") - menu:SetFrameStrata("FULLSCREEN_DIALOG") - GUI:RegisterMenu(menu) - menu:SetPoint("TOPLEFT", ddBtn, "BOTTOMLEFT", 0, -1) - menu:SetWidth(150) - CreatePanelBackdrop(menu) - menu:Hide() - - -- Row pool: frames can't be garbage-collected in WoW, so recreating the - -- items on every open (the old Hide+SetParent(nil) approach) leaked a row - -- set per click. Reuse instead. - local menuRows = {} - local function BuildMenu() - for _, row in ipairs(menuRows) do row:Hide() end - local used = 0 - local y = -4 - local function AddItem(label, onClick) - used = used + 1 - local item = menuRows[used] - if not item then - item = CreateFrame("Button", nil, menu) - item:SetHeight(20) - item.text = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - item.text:SetPoint("LEFT", 4, 0) - item:SetScript("OnEnter", function(s) s.text:SetTextColor(1, 1, 1) end) - item:SetScript("OnLeave", function(s) s.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end) - item:SetScript("OnClick", function(s) - s.onClick() - menu:Hide() - bar:Refresh() - onChange() - end) - menuRows[used] = item - end - item:ClearAllPoints() - item:SetPoint("TOPLEFT", 4, y) - item:SetPoint("TOPRIGHT", -4, y) - item.text:SetText(label) - item.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - item.onClick = onClick - item:Show() - y = y - 20 - end - -- "Inherit (Global)" — only while editing a raid auto-layout. Clears the - -- layout's preset override so it follows your global preset. - if IsEditingLayout() then - AddItem(L["Inherit (Global)"], function() - if DF.InheritLayoutDesignerPreset then DF:InheritLayoutDesignerPreset(kind) end - end) - end - for _, name in ipairs(DF:ListDesignerPresets(kind)) do - AddItem(name, function() DF:SetModeDesignerPreset(kind, getMode(), name) end) - end - menu:SetHeight(-y + 4) - end - ddBtn:SetScript("OnClick", function() - if menu:IsShown() then menu:Hide() else BuildMenu(); menu:Show() end - end) - - -- SHARING MARKER. A template can be pointed at by the other mode, a pinned - -- set or an auto layout, and editing it then changes every one of them — - -- which nothing on this bar used to say. The dropdown is a fixed 150px, so - -- the FACT rides as a glyph and the NAMES go in the tooltip, which is free. - -- - -- Deliberately NOT clickable. Splitting a shared template off for this mode - -- is exactly what Duplicate does, two buttons to the right, and Duplicate - -- also lets you name the copy. - local shareIcon = ddBtn:CreateTexture(nil, "OVERLAY") - shareIcon:SetSize(12, 12) - shareIcon:SetPoint("RIGHT", arrow, "LEFT", -3, 0) - shareIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync") - shareIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - shareIcon:Hide() - - -- Read off the REFS, not the party/raid sync flag: sharing by hand (picking - -- the other mode's preset from this dropdown) counts exactly the same, and a - -- ref can't fall out of step with itself. - local function SharedWith() - if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then - return {} -- inheriting: this bar isn't sitting on a preset of its own - end - return (DF.ListDesignerPresetUsers and DF:ListDesignerPresetUsers(kind, CurrentName(), getMode())) or {} - end - - -- The consumers (Party/Raid, Auto Layouts, Pinned Frames) are spread across - -- three other pages, so naming them is the one thing this tooltip has to do - -- — the bar itself already shows what a template is. "can" holds for all - -- four: Auto Layouts and Pinned Frames may inherit their mode's instead - -- (a nil ref), and Party/Raid resolve to a default when nothing is set. - -- Names match their page titles so they are findable. - ddBtn:SetScript("OnEnter", function(self) - local lines = { - L["Templates can be used by Party, Raid, Auto Layouts and Pinned Frames."], - } - local shared = SharedWith() - if #shared > 0 then - lines[#lines + 1] = " " - lines[#lines + 1] = { text = format(L["Also used by: %s"], table.concat(shared, ", ")), accent = true } - -- "there" points back at the list above, so the consequence needs no - -- nouns of its own. It has to be said: a shared template's edits - -- reach a screen you are not looking at, and naming the users is - -- only the fact, not the warning. - lines[#lines + 1] = L["Edits apply there too."] - end - GUI:ShowTooltip(self, { title = L["Templates"], lines = lines }) - end) - ddBtn:SetScript("OnLeave", function() GUI:HideTooltip() end) - - -- When editing a raid auto-layout, default the NEW preset name to the - -- layout's name (e.g. editing "31-40" → prefill "31-40") so making a - -- per-layout preset is one click + Enter. nil (blank) otherwise. (Duplicate - -- names after its source preset, not the layout.) - local function EditingLayoutName() - if not IsEditingLayout() then return nil end -- mode-gated (raid only) - local apu = DF.AutoProfilesUI - if apu and apu.editingProfile then - return apu.editingProfile.name - end - return nil - end - - -- Action buttons. opts.iconButtons = true swaps the labeled buttons for - -- compact tooltipped icon-only buttons (22x22) — used where the bar shares - -- a row with other controls (Aura Designer header). Default stays labeled - -- (Text Designer) so existing callers are untouched. - local function CreateAction(labelText, iconName, width, onClick) - if opts.iconButtons then - local b = CreateFrame("Button", nil, bar, "BackdropTemplate") - GUI:StyleButton(b, { - width = 22, height = 22, - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, - size = 14, color = C_TEXT, - }, - }) - b:SetScript("OnClick", function(self) - onClick(self) - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - b:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = labelText}) - end) - b:HookScript("OnLeave", function() GUI:HideTooltip() end) - return b - end - return GUI:CreateButton(bar, labelText, width, 22, onClick) - end - - local newBtn = CreateAction(L["New"], "add", 48, function() - PromptPresetName(L["Name the new template:"], EditingLayoutName() or "", L["Create"], function(text) - local n = DF:CreateDesignerPreset(kind, text) - if n then - DF:SetModeDesignerPreset(kind, getMode(), n) - bar:Refresh(); onChange() - end - end) - end) - newBtn:SetPoint("LEFT", ddBtn, "RIGHT", 6, 0) - - local dupBtn = CreateAction(L["Duplicate"], "content_copy", 72, function() - local cur = CurrentName() - -- Duplicate defaults to " copy" (New uses the layout name, but a - -- duplicate is of a specific preset, so name it after the source). - PromptPresetName(L["Name the duplicated template:"], cur .. " copy", L["Duplicate"], function(text) - local n = DF:DuplicateDesignerPreset(kind, cur, text) - if n then - DF:SetModeDesignerPreset(kind, getMode(), n) - bar:Refresh(); onChange() - end - end) - end) - dupBtn:SetPoint("LEFT", newBtn, "RIGHT", 4, 0) - - local renameBtn = CreateAction(L["Rename"], "edit", 62, function() - local cur = CurrentName() - if cur == DF.DEFAULT_PRESET then return end - PromptPresetName(L["Rename template:"], cur, L["Rename"], function(text) - DF:RenameDesignerPreset(kind, cur, text) - bar:Refresh(); onChange() - end) - end) - renameBtn:SetPoint("LEFT", dupBtn, "RIGHT", 4, 0) - - local delBtn = CreateAction(L["Delete"], "delete", 56, function() - local cur = CurrentName() - if cur == DF.DEFAULT_PRESET then return end - ConfirmDeletePreset(kind, cur, function() bar:Refresh(); onChange() end) - end) - delBtn:SetPoint("LEFT", renameBtn, "RIGHT", 4, 0) - - local function SetActionEnabled(btn, on) - if on then - btn:Enable() - if btn.Text then btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end - if btn.Icon then btn.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end - else - btn:Disable() - -- greyed: Default can't be renamed/deleted - if btn.Text then btn.Text:SetTextColor(0.4, 0.4, 0.4) end - if btn.Icon then btn.Icon:SetVertexColor(0.4, 0.4, 0.4) end - end - end - - function bar:Refresh() - ddBtn.text:SetText(CurrentLabel()) - -- Make room for the share glyph only while it's up, so an unshared - -- preset keeps the full label width. - local isShared = #SharedWith() > 0 - shareIcon:SetShown(isShared) - ddBtn.text:ClearAllPoints() - ddBtn.text:SetPoint("LEFT", 6, 0) - ddBtn.text:SetPoint("RIGHT", isShared and -30 or -16, 0) - -- Rename/Delete act on the resolved preset; disable for the non-editable - -- Default and while a layout is inheriting (you're following the global, - -- not sitting on a layout-specific preset). - local inheriting = IsEditingLayout() and DF.IsLayoutDesignerInheriting - and DF:IsLayoutDesignerInheriting(kind) - local canModify = (CurrentName() ~= DF.DEFAULT_PRESET) and not inheriting - SetActionEnabled(renameBtn, canModify) - SetActionEnabled(delBtn, canModify) - end - - bar:Refresh() - -- The sharing glyph reflects OTHER refs (the other mode, a pinned set, an - -- auto layout), which can change without changing THIS mode's — and both - -- designer pages early-return their rebuild when their own preset is - -- unchanged, so the glyph would sit stale. Register the live bar so the - -- shared page refresh can reach it. One slot per kind: a rebuilt page - -- overwrites its own entry, and a torn-down bar is hidden, so nothing - -- accumulates. - GUI._designerPresetBars = GUI._designerPresetBars or {} - GUI._designerPresetBars[kind] = bar - return bar -end - -function GUI:RefreshDesignerPresetBars() - for _, bar in pairs(GUI._designerPresetBars or {}) do - if bar.Refresh and bar:IsShown() then bar:Refresh() end - end -end --- Creates a button with an icon and text --- iconName is the name of the icon file (without path/extension) --- iconSize is optional (defaults to 16) --- align: "center" (default) or "left". Pass "left" for wide / full-width --- list-style buttons where centred content floats (see GUI:StyleButton). -function GUI:CreateIconButton(parent, iconName, text, width, height, func, iconSize, align) - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleButton(btn, { - width = width or 120, height = height or 22, - text = text, - align = align, - icon = { - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, - size = iconSize or 18, - color = C_TEXT, - }, - }) - btn:SetScript("OnClick", function(self) - if func then func(self) end - PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) - end) - return btn -end --- Creates a \"See Also:\" section with clickable links to related pages --- links = { {pageId = \"display_tooltips\", label = \"Tooltips\"}, ... } -function GUI:CreateSeeAlso(parent, links) - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - container:SetHeight(32) - -- The page's own footer: pinned to the bottom of the viewport on a SHORT - -- page instead of floating wherever the content happened to end. See the - -- footer block in the page layout. - container.isPageFooter = true - CreateElementBackdrop(container, { - bgColor = { 0.1, 0.1, 0.1, 0.5 }, - borderColor = { 0.3, 0.3, 0.3, 0.8 }, - }) - - local label = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - label:SetPoint("TOPLEFT", 8, -10) - label:SetText(L["See Also:"]) - label:SetTextColor(0.7, 0.7, 0.7) - - local linkButtons = {} - local separators = {} - - for i, linkData in ipairs(links) do - local link = CreateFrame("Button", nil, container) - link:SetHeight(16) - - local linkText = link:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - linkText:SetPoint("TOPLEFT", 0, -1) - linkText:SetText(linkData.label) - local c = GetThemeColor() - linkText:SetTextColor(c.r, c.g, c.b) - link.text = linkText - link.textWidth = linkText:GetStringWidth() + 4 - link:SetWidth(link.textWidth) - - link:SetScript("OnEnter", function(self) - local h = GUI:LinkHoverColor(c) - linkText:SetTextColor(h.r, h.g, h.b) - end) - link:SetScript("OnLeave", function(self) - linkText:SetTextColor(c.r, c.g, c.b) - end) - link:SetScript("OnClick", function() - if GUI.SelectTab then - GUI.SelectTab(linkData.pageId) - end - end) - - table.insert(linkButtons, link) - - -- Create separator (hidden by default, shown as needed) - if i < #links then - local sep = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - sep:SetText("•") - sep:SetTextColor(0.5, 0.5, 0.5) - table.insert(separators, sep) - end - end - - -- Layout function that handles wrapping - local function LayoutLinks() - local containerWidth = container:GetWidth() - if containerWidth < 50 then return end -- Not sized yet - - local labelWidth = label:GetStringWidth() + 16 - local firstLinkX = labelWidth -- Where first link starts - local xOffset = labelWidth - local yOffset = -9 - local lineHeight = 18 - local maxX = containerWidth - 10 - local rowCount = 1 - - -- First pass: determine which links are on which row - local linkRows = {} - local tempX = labelWidth - local currentRow = 1 - - for i, link in ipairs(linkButtons) do - local linkWidth = link.textWidth - local sepWidth = (i < #linkButtons) and 14 or 0 - - -- Check if we need to wrap - if tempX + linkWidth > maxX and tempX > labelWidth then - currentRow = currentRow + 1 - tempX = firstLinkX - end - - linkRows[i] = currentRow - tempX = tempX + linkWidth + sepWidth - end - - rowCount = currentRow - - -- Second pass: position elements - xOffset = labelWidth - local lastRowForLink = 1 - - for i, link in ipairs(linkButtons) do - local linkWidth = link.textWidth - - -- Check if we need to wrap to new line - if linkRows[i] > lastRowForLink then - xOffset = firstLinkX - yOffset = yOffset - lineHeight - lastRowForLink = linkRows[i] - end - - link:ClearAllPoints() - link:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset) - - xOffset = xOffset + linkWidth + 2 - - -- Position separator only if next link is on same row - if separators[i] then - if linkRows[i + 1] == linkRows[i] then - separators[i]:ClearAllPoints() - separators[i]:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset - 1) - separators[i]:Show() - xOffset = xOffset + 12 - else - separators[i]:Hide() - end - end - end - - -- Adjust container height based on rows. - -- - -- Snapped HERE, where the number is computed, because this widget - -- MEASURES ITSELF: LayoutLinks runs from OnSizeChanged and from a - -- C_Timer.After(0), i.e. a frame AFTER the page layout has run. - -- Correcting the height after the fact cannot win -- whatever sets a - -- grid-aligned height, this function overwrites it on the next frame, - -- and the bar's bottom edge ends up split across two device rows. - -- Measured: 28 units at 1.40625 px/unit is 39.375px, 0.375 off. - local newHeight = SnapLen(container, 10 + (rowCount * lineHeight)) - container:SetHeight(newHeight) - container.layoutHeight = newHeight + 5 - end - - container:SetScript("OnSizeChanged", LayoutLinks) - - -- Initial layout after a frame (to let width be set) - C_Timer.After(0, LayoutLinks) - - return container -end -- ========================================================================= -- OVERRIDE INDICATORS FOR AUTO PROFILES @@ -1404,75 +888,6 @@ function GUI:CreateOverrideMarker(parent, size) return btn end --- A bare checkbox sized for a LIST ROW — no label, no db binding, no settings-row --- geometry. GUI:CreateCheckbox is a whole 30px settings row with its own label and --- hit rect, which is the wrong shape entirely inside a 22px pooled list row that --- already owns its own text, count and selection accent. --- --- The caller drives it: :SetChecked(bool) to paint, opts.onClick to react. It does --- NOT read or write the db itself, because a list row's meaning changes per bind --- (a pooled row is a different filter every refresh) and a captured dbKey would go --- stale the moment the pool rebinds. --- --- ⚠ Clicks deliberately do NOT propagate. The override marker above lets them fall --- through because it is a passive marker; this is a control, and on a clickable row --- the two gestures must stay separate — tick the box to switch the filter on, click --- anywhere else to select it. Nothing here calls SetPropagateMouseClicks, which is --- PROTECTED on 12.1 anyway (see the note in CreateOverrideMarker). --- --- ⚠ The BOX ITSELF is GUI:StyleCheckButton, the addon's one checkbox look — do not --- hand-roll it again. This was hand-rolled once and drifted four ways: it drew the --- Media\Icons\check GLYPH where every other checkbox in the addon draws a filled --- WHITE8x8 square (a different SYMBOL, not a different style), it skipped PixelUtil --- so it alone was unsnapped, it had no hover wash, and it recoloured its BORDER when --- checked, which nothing else does. CreateDebugCategoryRow is the precedent for this --- exact case — a list row with a checkbox — at the same size. --- --- manualCheck because this is a plain Button, not a CheckButton: SetChecked below --- drives the mark. A real CheckButton would draw its checked mark through the native --- checked state, which has no disabled-checked texture here — so a greyed-but-ticked --- row (every filter row while All Buffs is on) would lose its tick entirely. --- --- opts: { size = 16, checkSize = 9, onClick = function(checked) end, --- tooltip = title, tooltipDesc = line } -function GUI:CreateRowToggle(parent, opts) - opts = opts or {} - local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") - GUI:StyleCheckButton(btn, { - size = opts.size or 16, - checkSize = opts.checkSize or 9, - manualCheck = true, - }) - - btn.checked = false - function btn:SetChecked(on) - self.checked = on and true or false - -- Re-tint on every paint. StyleCheckButton registers its theme listener on - -- this button's PARENT, which for a pooled list row is a frame inside a - -- scroll child that the page's theme walk never visits (same trap as - -- StyleButton's wash). The list rebinds every row on refresh, and a refresh - -- is what a mode switch produces, so painting the accent here is what - -- actually carries party purple -> raid orange. - self.ApplyThemeColor(GetThemeColor()) - self.Check:SetShown(self.checked) - end - - btn:SetScript("OnClick", function(s) - if s.onClick then s.onClick(not s.checked) end - end) - btn:SetScript("OnEnter", function(s) - if s.tooltipText then - GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipDesc and { s.tooltipDesc } or nil }) - end - end) - btn:SetScript("OnLeave", function() GUI:HideTooltip() end) - - btn.onClick = opts.onClick - btn.tooltipText = opts.tooltip - btn.tooltipDesc = opts.tooltipDesc - btn:SetChecked(false) - return btn -end -- A "reset to global" button — red, icon-only, danger tone (matches the Reset -- Page button). Returns a hidden Button. opts: { size = 18, tooltip = title, @@ -1831,138 +1246,6 @@ function GUI:StyleCheckButton(cb, opts) return check end -function GUI:CreateCheckbox(parent, label, dbTable, dbKey, callback, customGet, customSet, overrideKey) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(220, 24) - container.preferredHeight = GUI.RowHeight.checkbox -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "checkbox" -- /df debug gapcheck groups the spacing report by this - container.fixedRowHeight = true - - local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") - cb:SetPoint("LEFT", 0, 0) - -- Box + themed check come from the shared styler (single source of truth). - GUI:StyleCheckButton(cb, { themeRoot = parent }) - - -- Label - local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - txt:SetText(label) - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - container.label = txt -- exposed so callers can re-font / anchor a subtitle - - -- Determine the key to use for override indicators - local effectiveOverrideKey = overrideKey or dbKey - - -- Add override indicators if we have a key (either dbKey or overrideKey) - if effectiveOverrideKey and type(effectiveOverrideKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(effectiveOverrideKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(effectiveOverrideKey) - cb:SetChecked(globalVal) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - elseif customSet then - customSet(globalVal) - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(container, txt, effectiveOverrideKey, onReset, nil, nil, dbTable) - end - - local function UpdateState() - local val = false - if customGet then val = customGet() elseif dbTable and dbKey then val = dbTable[dbKey] end - cb:SetChecked(val) - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - end - - container:SetScript("OnShow", UpdateState) - -- Re-read the source and repaint the box, for a caller that changed the value - -- behind the widget's back (a "set all" button). Same contract as - -- CreateSegmentToggle:Refresh(); before this, call sites reached for a - -- Hide()/Show() bounce to fire the OnShow above. - container.Refresh = UpdateState - cb:SetScript("OnClick", function(self) - local val = self:GetChecked() - -- Was gated on DF.debugEnabled and printed straight to CHAT, bypassing the - -- console entirely. GUI is the right category and it is already declared. - DF:Debug("GUI", "checkbox OnClick: dbKey=%s overrideKey=%s value=%s", - tostring(dbKey), tostring(overrideKey), tostring(val)) - - -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(effectiveOverrideKey, val) then - if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end - return - end - - if customSet then customSet(val) elseif dbTable and dbKey then dbTable[dbKey] = val end - - -- If editing a profile, also set the override (use effectiveOverrideKey) - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and effectiveOverrideKey then - DF.AutoProfilesUI:SetProfileSetting(effectiveOverrideKey, val) - end - - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(val) - end - - if callback then - DF:Debug("GUI", "checkbox OnClick: calling callback") - callback() - end - if parent.RefreshStates then - DF:Debug("GUI", "checkbox OnClick: calling RefreshStates") - parent:RefreshStates() - end - DF:Debug("GUI", "checkbox OnClick: calling DF:UpdateAll") - DF:UpdateAll() - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget (box + check fill + label) so a disabled CHECKED - -- box greys too: native SetEnabled has no DisabledCheckedTexture, so the - -- accent check would otherwise stay full-bright. - self:SetAlpha(enabled and 1 or 0.4) - cb:SetEnabled(enabled) - if enabled then - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The - -- earlier hit-rect arithmetic here is gone with it — the hit frame is anchored - -- to the FontString, so a label overflowing the fixed 220 container is covered - -- for free rather than by widening the container's hit rect to match. - GUI:AttachTooltip(container, label, txt) - - UpdateState() - - -- SEARCH: Register this setting - if DF.Search then - local hasCustomGetSet = (customGet ~= nil or customSet ~= nil) - if dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterCheckbox(label, dbKey, nil, false, callback) - elseif hasCustomGetSet then - container.searchEntry = DF.Search:RegisterCheckbox(label, nil, nil, true, callback) - end - end - - return container -end -- ============================================================ -- SEGMENT TOGGLE @@ -1985,82 +1268,6 @@ end -- dbTable/dbKey as nil when using these. -- Returns the container with :Refresh(), :refreshContent() and :SetEnabled(). -- ============================================================ -function GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) - opts = opts or {} - local segW = opts.segmentWidth or 26 - local h = opts.height or 18 - local pad = 1 -- track lip around the buttons - - local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") - container:SetSize(segW * #segments + pad * 2, h + pad * 2) - CreateElementBackdrop(container) -- the recessed track behind every segment - - -- One read/write pair for both pathways, so the click handler and Refresh - -- can't drift apart. Explicit ifs, not `a and b or c` — a stored value of - -- false/nil is legitimate. - local function GetValue() - if opts.customGet then return opts.customGet() end - if dbTable and dbKey then return dbTable[dbKey] end - end - local function SetValue(v) - if opts.customSet then opts.customSet(v) return true end - if dbTable and dbKey then dbTable[dbKey] = v return true end - return false - end - - local buttons = {} - for i, seg in ipairs(segments) do - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - GUI:StyleButton(btn, { width = segW, height = h, text = seg.label }) - GUI:SetSettingsFont(btn.Text, 9, "") - btn:SetPoint("TOPLEFT", container, "TOPLEFT", pad + (i - 1) * segW, -pad) - btn.value = seg.value - if seg.tooltip then - btn:HookScript("OnEnter", function(self) - GUI:ShowTooltip(self, { title = seg.tooltip, lines = opts.tooltipLines }) - end) - btn:HookScript("OnLeave", function() GUI:HideTooltip() end) - end - btn:SetScript("OnClick", function(self) - if GetValue() == self.value then return end - if not SetValue(self.value) then return end - container:Refresh() - if callback then callback(self.value) end - end) - buttons[i] = btn - end - - -- Selection: the shared accent border/fill via SetActive, plus a bright/dim - -- label so the state still reads at a glance in a themed accent that is close - -- to the resting border colour. - function container:Refresh() - local cur = GetValue() - local matched = false - for _, b in ipairs(buttons) do if b.value == cur then matched = true end end - if not matched then cur = opts.fallbackValue end - for _, b in ipairs(buttons) do - local on = (b.value == cur) - b:SetActive(on) - if b.Text then - local c = on and C_TEXT or C_TEXT_DIM - b.Text:SetTextColor(c.r, c.g, c.b) - end - end - end - container.refreshContent = function(self) self:Refresh() end - - container.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - for _, b in ipairs(buttons) do b:EnableMouse(enabled) end - end - - container.UpdateTheme = function() container:Refresh() end - if not parent.ThemeListeners then parent.ThemeListeners = {} end - table.insert(parent.ThemeListeners, container) - - container:Refresh() - return container -end -- ============================================================ @@ -2073,135 +1280,6 @@ end -- Used by the Debug > Categories sub-tab. The categoryKey writes -- directly to DandersFramesDB_v2.debug.filters. -- ============================================================ --- opts.noisy marks a firehose category: one user action can produce dozens of --- lines, which evicts the trace the log was opened to capture. It renders as the --- shared caution icon with its own tooltip rather than a "(noisy)" suffix baked --- into the description string -- the suffix was untranslatable in place, and it --- competed with the description for the same line of text. -function GUI:CreateDebugCategoryRow(parent, categoryKey, description, width, noisy) - local row = CreateFrame("Frame", nil, parent) - row:SetSize(width or 520, 28) - row:EnableMouse(true) - - -- Hover background - row.hoverBg = row:CreateTexture(nil, "BACKGROUND") - row.hoverBg:SetAllPoints() - row.hoverBg:SetColorTexture(1, 1, 1, 0.05) - row.hoverBg:Hide() - - -- Checkbox - local cb = CreateFrame("CheckButton", nil, row, "BackdropTemplate") - cb:SetPoint("LEFT", 4, 0) - GUI:StyleCheckButton(cb, { size = 16, checkSize = 9, themeRoot = parent }) - cb:EnableMouse(false) -- forward clicks to the row - - -- Category name (bold, full opacity) - local nameTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlight") - nameTxt:SetPoint("LEFT", cb, "RIGHT", 8, 0) - nameTxt:SetWidth(86) - nameTxt:SetJustifyH("LEFT") - nameTxt:SetText(categoryKey) - nameTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Caution icon for a firehose category. Its own hit area, so it can carry a - -- different tooltip from the row without stealing the row's click: the frame - -- only enables mouse, it has no OnMouseUp, so a click still falls through to - -- the row underneath and toggles the category like anywhere else on it. - local noisyIcon - if noisy then - noisyIcon = CreateFrame("Frame", nil, row) - noisyIcon:SetSize(14, 14) - noisyIcon:SetPoint("RIGHT", row, "RIGHT", -8, 0) - noisyIcon:EnableMouse(true) - noisyIcon:SetFrameLevel(row:GetFrameLevel() + 2) - local tex = noisyIcon:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints() - tex:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning") - -- The caution tone's ICON colour, read straight from the shared tone table - -- so this stays in step with every banner and note that uses it. - -- ☠ Read at CALL time, not through a file-scope alias. INFO_BANNER_TONES - -- is defined in GUI/Sections.lua, which lives in the load-on-demand - -- companion, while this file is resident -- an alias captured at load - -- would be nil forever and never see the companion's later publish. - -- This function is only ever called from a settings page, so the - -- companion is loaded by the time it runs. - local ic = GUI._priv.INFO_BANNER_TONES.caution.iconColor - tex:SetVertexColor(ic[1], ic[2], ic[3]) - noisyIcon:SetScript("OnEnter", function(self) - -- Keep the row's wash up: the pointer is still over the row, and - -- letting it drop would read as the row losing focus. - row.hoverBg:Show() - GUI:ShowTooltip(self, { - title = L["Noisy category"], - lines = { L["This category can fill the log very quickly, burying the entries you are looking for."], - L["Turn it on only while reproducing the bug it relates to."] }, - tone = "caution", - }) - end) - noisyIcon:SetScript("OnLeave", function() - row.hoverBg:Hide() - GUI:HideTooltip() - end) - row.noisyIcon = noisyIcon - end - - -- Description (dim, fills remaining space, wraps if too long) - if description and description ~= "" then - local descTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - descTxt:SetPoint("LEFT", nameTxt, "RIGHT", 12, 0) - -- Stop short of the icon rather than running under it. - if noisyIcon then - descTxt:SetPoint("RIGHT", noisyIcon, "LEFT", -6, 0) - else - descTxt:SetPoint("RIGHT", row, "RIGHT", -8, 0) - end - descTxt:SetJustifyH("LEFT") - descTxt:SetText(description) - descTxt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - row.descTxt = descTxt - end - - -- State helpers — read/write filters[categoryKey] - -- Absent or true = logged, explicit false = not logged - row.RefreshState = function() - local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters - local checked = (not filters) or filters[categoryKey] ~= false - cb:SetChecked(checked) - end - - local function ToggleState() - local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters - if not filters then return end - -- Toggle: false -> true, anything else -> false - if filters[categoryKey] == false then - filters[categoryKey] = true - else - filters[categoryKey] = false - end - row.RefreshState() - if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end - end - - row:SetScript("OnMouseUp", function(_, button) - if button == "LeftButton" then ToggleState() end - end) - - row:SetScript("OnEnter", function(self) - self.hoverBg:Show() - if description and description ~= "" then - GUI:ShowTooltip(self, { title = categoryKey, lines = { description } }) - end - end) - row:SetScript("OnLeave", function(self) - self.hoverBg:Hide() - GUI:HideTooltip() - end) - - row:SetScript("OnShow", row.RefreshState) - row.RefreshState() - - return row -end -- The one input "well": translucent-black fill + dim edge. Shared by StyleEditBox -- (the single-line field) and CreateTextArea (the scrolling multi-line container) @@ -2234,179 +1312,7 @@ function GUI:StyleEditBox(eb, opts) return eb end -function GUI:CreateInput(parent, label, width) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 180, 44) - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - local editbox = CreateFrame("EditBox", nil, frame) - -- Two-corner anchored, so the offset and the height are the ONLY levers -- - -- Nothing corrects a frame's position at runtime, and controls are not - -- position-corrected at all any more. Snap both and all four edges land. - local ebY = SnapLen(editbox, -15) or -15 - editbox:SetPoint("TOPLEFT", 0, ebY) - editbox:SetPoint("TOPRIGHT", 0, ebY) - editbox:SetHeight(SnapLen(editbox, 24) or 24) - GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets - editbox:SetAutoFocus(false) - editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - editbox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) - - -- Grey-when-disabled parity with CreateEditBox (cheap insurance if ever placed - -- in a gated group): dim the whole widget + block editing. - frame.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - editbox:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - frame.EditBox = editbox - -- Tooltip: shared attach on the LABEL only. This factory carried no tooltip - -- support at all, so a caller that set .tooltip on it got silence — - -- Options.lua's custom range-spell input did exactly that, and its - -- explanation never appeared. Keeping it off the edit box also means it can't - -- cover what you are typing. - GUI:AttachTooltip(frame, label, lbl) - return frame -end - --- CreateEditBox: Text input with db binding (for settings like custom text) -function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) - local frame = CreateFrame("Frame", nil, parent) - frame:SetSize(width or 180, 44) - frame.preferredHeight = GUI.RowHeight.editbox -- factory-owned slot height (see GUI.RowHeight) - frame.rowKind = "editbox" - frame.fixedRowHeight = true - - local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetPoint("TOPLEFT", 0, 0) - lbl:SetText(label) - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Add override indicators if dbKey is provided - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if dbTable and dbKey then - dbTable[dbKey] = globalVal - end - if frame.EditBox then - frame.EditBox:SetText(globalVal or "") - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(globalVal) - end - DF:UpdateAll() - if callback then callback() end - end - end - AddOverrideIndicators(frame, lbl, dbKey, onReset, 6, nil, dbTable) - end - - local editbox = CreateFrame("EditBox", nil, frame) - -- Two-corner anchored, so the offset and the height are the ONLY levers -- - -- Nothing corrects a frame's position at runtime, and controls are not - -- position-corrected at all any more. Snap both and all four edges land. - local ebY = SnapLen(editbox, -15) or -15 - editbox:SetPoint("TOPLEFT", 0, ebY) - editbox:SetPoint("TOPRIGHT", 0, ebY) - editbox:SetHeight(SnapLen(editbox, 24) or 24) - GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets - editbox:SetAutoFocus(false) - - -- Set initial value from db - if dbTable and dbKey then - editbox:SetText(dbTable[dbKey] or "") - end - - -- Save on enter or focus lost - local function SaveValue() - if dbTable and dbKey then - local val = editbox:GetText() - -- Runtime override protection - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI - and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then - if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(val) end - return - end - dbTable[dbKey] = val - -- Track override when editing a profile - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then - DF.AutoProfilesUI:SetProfileSetting(dbKey, val) - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(val) - end - if callback then callback() end - end - end - - editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - editbox:SetScript("OnEnterPressed", function(self) - SaveValue() - self:ClearFocus() - end) - editbox:SetScript("OnEditFocusLost", SaveValue) - - -- Optional placeholder: greyed example text shown while the box is empty - -- and unfocused. Purely cosmetic — never written to the db. - if placeholder and placeholder ~= "" then - local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") - ph:SetPoint("LEFT", 5, 0) - ph:SetPoint("RIGHT", -5, 0) - ph:SetJustifyH("LEFT") - ph:SetText(placeholder) - ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) - local function UpdatePlaceholder() - ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") - end - -- Exposed so a caller that puts something INSIDE the box (see - -- GUI:AddEditBoxIcon) can move the placeholder clear of it. - editbox.Placeholder = ph - editbox.UpdatePlaceholder = UpdatePlaceholder - editbox:HookScript("OnTextChanged", UpdatePlaceholder) - editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) - editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) - UpdatePlaceholder() - end - -- Refresh override indicators on show - frame:SetScript("OnShow", function() - if dbTable and dbKey then - editbox:SetText(dbTable[dbKey] or "") - end - if frame.UpdateOverrideIndicators then - frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) - end - if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end - end) - - -- Grey-when-disabled: the grey loop (RefreshChildStates) calls widget:SetEnabled, - -- but this frame had none, so a disabled group left the input full-bright AND - -- editable. Dim the whole widget + block editing, matching the other helpers. - frame.SetEnabled = function(self, enabled) - self:SetAlpha(enabled and 1 or 0.4) - editbox:SetEnabled(enabled) - if enabled then - lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - else - lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - end - end - - frame.EditBox = editbox - return frame -end -- ============================================================ -- LEADING ICON INSIDE AN EDIT BOX @@ -2418,24 +1324,6 @@ end -- -- Pass frame.EditBox, not the frame. -- ============================================================ -function GUI:AddEditBoxIcon(editbox, texture, size) - if not editbox or not texture then return end - size = size or 14 - local icon = editbox:CreateTexture(nil, "OVERLAY") - icon:SetSize(size, size) - icon:SetPoint("LEFT", 6, 0) - icon:SetTexture(texture) - icon:SetVertexColor(0.72, 0.72, 0.72) - editbox.Icon = icon - - local left = 6 + size + 5 - local _, right, top, bottom = editbox:GetTextInsets() - editbox:SetTextInsets(left, right, top, bottom) - if editbox.Placeholder then - editbox.Placeholder:SetPoint("LEFT", left, 0) - end - return icon -end -- ============================================================ -- TEXT AREA — the multi-line cousin of CreateEditBox @@ -2921,392 +1809,8 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c return container end --- Stamp the shared Frame Level explanation onto a slider. One helper rather than the same --- two strings at 21 call sites, and it keeps the wording in ONE place -- the old per-page --- label went stale the moment the scale changed (it still read "0=Auto" afterwards). --- Takes the CONTAINER that CreateSlider returns, which is what every call site has. -function GUI:SetFrameLevelTooltip(container) - if not container then return end - container.tooltipText = L["Frame Level"] - container.tooltipSubText = L["Higher numbers draw on top of lower ones. Every Frame Level in DandersFrames uses the same scale, counted up from the unit frame, so you can compare them directly."] - return container -- chainable, so it wraps a CreateSlider call in place -end - --- Dual-handle range slider: two draggable handles select a [lo, hi] sub-range of --- [minRange, maxRange]. Self-contained — the caller anchors the returned track --- frame and reads values via the onChange callback. (:GetValues() also exists and --- completes the SetValues pair, but no current consumer polls it.) Drag is --- tracked on the track's own OnUpdate (no dependence on parent scripts), and a --- mouse-button check releases the drag even if the cursor leaves the handle. --- opts: --- width(336), accent({r,g,b}=theme), minRange, maxRange, lo, hi, --- scaleLabels({...} optional tick labels), scaleMin/scaleMax (label scale, --- default minRange/maxRange — lets ticks stay on a fixed scale while the --- handle range changes), display(FontString updated each change), --- formatRange(fn(lo,hi)->str), formatOne(fn(v)->str), --- onChange(fn(lo,hi) — fired on user-driven changes only, not SetValues). --- Methods on the returned frame: :SetRange(min,max), :SetValues(lo,hi), --- :GetValues()->lo,hi. -function GUI:CreateRangeSlider(parent, opts) - opts = opts or {} - local width = opts.width or 336 - local accent = opts.accent or GetThemeColor() - - local track = CreateFrame("Frame", nil, parent, "BackdropTemplate") - track:SetSize(width, 12) - CreateElementBackdrop(track, { - bgColor = { 0.03, 0.03, 0.03, 1 }, - borderColor = { 0.2, 0.2, 0.2, 1 }, - }) - - track.minRange = opts.minRange or 1 - track.maxRange = opts.maxRange or 40 - track.lo = opts.lo or track.minRange - track.hi = opts.hi or track.maxRange - - local rangeFill = track:CreateTexture(nil, "ARTWORK") - rangeFill:SetTexture("Interface\\Buttons\\WHITE8x8") - rangeFill:SetVertexColor(accent.r, accent.g, accent.b, 0.5) - rangeFill:SetHeight(10) - rangeFill:SetPoint("TOP", 0, -1) - - local function MakeHandle() - local h = CreateFrame("Button", nil, track) - h:SetSize(8, 16) - h:EnableMouse(true) - local tex = h:CreateTexture(nil, "OVERLAY") - tex:SetAllPoints() - tex:SetTexture("Interface\\Buttons\\WHITE8x8") - tex:SetVertexColor(accent.r, accent.g, accent.b, 1) - return h - end - local minHandle, maxHandle = MakeHandle(), MakeHandle() - track.minHandle, track.maxHandle = minHandle, maxHandle - - local function ValueToPos(value) - local pct = (value - track.minRange) / (track.maxRange - track.minRange) - return pct * (width - 4) + 2 - end - local function PosToValue(pos) - local pct = (pos - 2) / (width - 4) - return math.floor(pct * (track.maxRange - track.minRange) + track.minRange + 0.5) - end - - local function Redraw() - local minPos, maxPos = ValueToPos(track.lo), ValueToPos(track.hi) - minHandle:ClearAllPoints() - minHandle:SetPoint("CENTER", track, "LEFT", minPos, 0) - maxHandle:ClearAllPoints() - maxHandle:SetPoint("CENTER", track, "LEFT", maxPos, 0) - rangeFill:ClearAllPoints() - rangeFill:SetPoint("LEFT", track, "LEFT", minPos, 0) - rangeFill:SetWidth(math.max(maxPos - minPos, 2)) - if opts.display then - if track.lo == track.hi then - opts.display:SetText(opts.formatOne and opts.formatOne(track.lo) or tostring(track.lo)) - else - opts.display:SetText(opts.formatRange and opts.formatRange(track.lo, track.hi) - or (track.lo .. " - " .. track.hi)) - end - end - end - - local dragging = nil - local function ApplyCursor() - local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() - local trackLeft = track:GetLeft() - if not trackLeft then return end - local pos = math.max(2, math.min(x - trackLeft, width - 2)) - local value = math.max(track.minRange, math.min(PosToValue(pos), track.maxRange)) - if dragging == "min" then - if value <= track.hi then track.lo = value end - elseif dragging == "max" then - if value >= track.lo then track.hi = value end - end - Redraw() - if opts.onChange then opts.onChange(track.lo, track.hi) end - end - - minHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "min" end end) - maxHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "max" end end) - track:SetScript("OnUpdate", function() - if not dragging then return end - if not IsMouseButtonDown("LeftButton") then dragging = nil; return end - ApplyCursor() - end) - - track:EnableMouse(true) - track:SetScript("OnMouseDown", function(_, b) - if b ~= "LeftButton" then return end - local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() - local trackLeft = track:GetLeft() - if not trackLeft then return end - local value = PosToValue(x - trackLeft) - if math.abs(value - track.lo) <= math.abs(value - track.hi) then - if value <= track.hi then track.lo = math.max(track.minRange, value) end - else - if value >= track.lo then track.hi = math.min(track.maxRange, value) end - end - Redraw() - if opts.onChange then opts.onChange(track.lo, track.hi) end - end) - - if opts.scaleLabels then - local sMin = opts.scaleMin or track.minRange - local sMax = opts.scaleMax or track.maxRange - for _, num in ipairs(opts.scaleLabels) do - local pct = (num - sMin) / (sMax - sMin) - local xPos = pct * (width - 4) + 2 - local lbl = track:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - lbl:SetText(num) - lbl:SetTextColor(0.35, 0.35, 0.35) - lbl:SetPoint("TOP", track, "BOTTOM", xPos - width / 2, -2) - end - end - - function track:SetRange(minR, maxR) - self.minRange, self.maxRange = minR, maxR - self.lo = math.max(minR, math.min(self.lo, maxR)) - self.hi = math.max(minR, math.min(self.hi, maxR)) - Redraw() - end - function track:SetValues(lo, hi) - self.lo = math.max(self.minRange, math.min(lo, self.maxRange)) - self.hi = math.max(self.minRange, math.min(hi, self.maxRange)) - Redraw() - end - function track:GetValues() return self.lo, self.hi end - - Redraw() - return track -end - -function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback, lightweightCallback, useLightweight) - local container = CreateFrame("Frame", nil, parent) - container:SetSize(260, 28) - container.preferredHeight = GUI.RowHeight.colorpicker -- factory-owned slot height (see GUI.RowHeight) - container.rowKind = "colorpicker" - container.fixedRowHeight = true - - -- Button - use relative anchoring so it resizes with container - local btn = CreateFrame("Button", nil, container, "BackdropTemplate") - btn:SetPoint("TOPLEFT", 0, 0) - btn:SetPoint("TOPRIGHT", 0, 0) - btn:SetHeight(SnapLen(btn, 24) or 24) - CreateElementBackdrop(btn) - - -- Label - local txt = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - txt:SetPoint("LEFT", 8, 0) - txt:SetText(label) - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Color swatch - local swatch = btn:CreateTexture(nil, "OVERLAY") - swatch:SetSize(40, 16) - swatch:SetPoint("RIGHT", -6, 0) - - -- Add override indicators if dbKey is provided (for auto profiles) - if dbKey and type(dbKey) == "string" then - local function onReset() - if DF.AutoProfilesUI then - DF.AutoProfilesUI:ResetProfileSetting(dbKey) - -- Refresh to global value - local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) - if globalVal then - dbTable[dbKey].r = globalVal.r - dbTable[dbKey].g = globalVal.g - dbTable[dbKey].b = globalVal.b - dbTable[dbKey].a = globalVal.a or 1 - end - if container.UpdateSwatch then - container:UpdateSwatch() - end - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(dbTable[dbKey]) - end - DF:UpdateAll() - end - end - AddOverrideIndicators(container, txt, dbKey, onReset, nil, nil, dbTable) - end - - local function UpdateSwatch() - if dbTable and dbKey and dbTable[dbKey] then - local c = dbTable[dbKey] - swatch:SetColorTexture(c.r, c.g, c.b, c.a or 1) - -- Update override indicators - if container.UpdateOverrideIndicators then - container:UpdateOverrideIndicators(c) - end - end - end - container.UpdateSwatch = UpdateSwatch -- Expose for reset - - btn:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - btn:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - -- Tooltip: shared attach on the LABEL only. This factory carried none, across - -- 87 colour pickers. The btn keeps its own hover scripts untouched — the hit - -- frame is over the text, not the swatch. - GUI:AttachTooltip(container, label, txt) - - btn:SetScript("OnClick", function() - if not dbTable then return end - local c = dbTable[dbKey] - if not c then - c = {r = 1, g = 1, b = 1, a = 1} - dbTable[dbKey] = c - end - - -- Store original values for cancel - local originalColor = {r = c.r, g = c.g, b = c.b, a = c.a or 1} - - -- Blizzard's SetupColorPickerAndShow fires swatchFunc once DURING - -- setup (its SetColorRGB triggers OnColorSelect — the source comments - -- it). That spurious fire re-writes the unchanged colour and runs the - -- change callbacks on mere open: it commits per-element override flags - -- (Text Designer) and triggers a pointless full refresh. Suppress - -- callbacks until setup has returned. - local settingUp = true - - local info = { - swatchFunc = function() - if settingUp then return end - local r, g, b = ColorPickerFrame:GetColorRGB() - local a = 1 - if hasAlpha and ColorPickerFrame.GetColorAlpha then - a = ColorPickerFrame:GetColorAlpha() or 1 - end - dbTable[dbKey].r = r - dbTable[dbKey].g = g - dbTable[dbKey].b = b - dbTable[dbKey].a = a - - -- If editing a profile, also set the override - if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then - DF.AutoProfilesUI:SetProfileSetting(dbKey, {r = r, g = g, b = b, a = a}) - end - - UpdateSwatch() - -- Use lightweight callback during dragging if available - if useLightweight and lightweightCallback then - lightweightCallback() - else - DF:ThrottledUpdateAll() - if callback then callback() end - end - end, - hasOpacity = hasAlpha, - opacityFunc = hasAlpha and function() - if settingUp then return end - if ColorPickerFrame.GetColorAlpha then - local a = ColorPickerFrame:GetColorAlpha() - if a then - dbTable[dbKey].a = a - UpdateSwatch() - -- Use lightweight callback during dragging if available - if useLightweight and lightweightCallback then - lightweightCallback() - else - DF:ThrottledUpdateAll() - if callback then callback() end - end - end - end - end or nil, - cancelFunc = function(restore) - -- Restore original color on cancel - dbTable[dbKey].r = originalColor.r - dbTable[dbKey].g = originalColor.g - dbTable[dbKey].b = originalColor.b - dbTable[dbKey].a = originalColor.a - UpdateSwatch() - DF:UpdateAll() - if callback then callback() end - end, - r = c.r or 1, - g = c.g or 1, - b = c.b or 1, - opacity = hasAlpha and (c.a or 1) or nil, - } - - -- Hook the OK button to run full update when confirmed - if useLightweight and lightweightCallback then - -- We need to run full update when picker is closed via OK - -- Use a frame to detect when color picker closes - if not container.colorPickerWatcher then - container.colorPickerWatcher = CreateFrame("Frame") - end - container.colorPickerWatcher:SetScript("OnUpdate", function(self) - if not ColorPickerFrame:IsShown() then - self:SetScript("OnUpdate", nil) - -- Only run if color changed (not cancelled) - local cur = dbTable[dbKey] - if cur.r ~= originalColor.r or cur.g ~= originalColor.g or - cur.b ~= originalColor.b or cur.a ~= originalColor.a then - DF:UpdateAll() - if callback then callback() end - end - end - end) - end - - -- Attach default colour so the picker can offer a Default button - -- dbTable.__dfDefaults is set by callers (e.g. Aura Designer proxies) that - -- store their defaults outside DF.PartyDefaults / DF.RaidDefaults. Read via - -- rawget so proxies' __index doesn't see this lookup as a regular setting. - local defaultVal = (dbTable and rawget(dbTable, "__dfDefaults") and dbTable.__dfDefaults[dbKey]) - or (DF.PartyDefaults and DF.PartyDefaults[dbKey]) - or (DF.RaidDefaults and DF.RaidDefaults[dbKey]) - -- Fallback: power bar colours use WoW's PowerBarColor table as their default - if not defaultVal and PowerBarColor and dbKey then - defaultVal = PowerBarColor[dbKey] - end - if defaultVal and type(defaultVal) == "table" and defaultVal.r then - info.dfDefaultColor = {r = defaultVal.r or 1, g = defaultVal.g or 1, b = defaultVal.b or 1, a = defaultVal.a or 1} - -- Populate ElvUI's "Default" button (ColorPPDefault) so it enables and - -- pastes the DF setting default when the native Blizzard picker is shown - local elvDefault = _G["ColorPPDefault"] - if elvDefault then - elvDefault.colors = info.dfDefaultColor - end - end - -- Mark this as a DandersFrames color picker call - GUI:MarkColorPickerCall() - ColorPickerFrame:SetupColorPickerAndShow(info) - settingUp = false - end) - - container.SetEnabled = function(self, enabled) - -- Dim the whole widget so the colour swatch greys even when it's a dark - -- colour (SetDesaturated alone is invisible on near-black swatches). - self:SetAlpha(enabled and 1 or 0.4) - btn:SetEnabled(enabled) - if enabled then - txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - swatch:SetDesaturated(false) - else - txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - swatch:SetDesaturated(true) - end - end - - btn:SetScript("OnShow", UpdateSwatch) - UpdateSwatch() - - -- SEARCH: Register this setting - if DF.Search and dbKey and type(dbKey) == "string" then - container.searchEntry = DF.Search:RegisterColorPicker(label, dbKey, hasAlpha, nil, callback) - end - - return container -end -- ============================================================ -- OPEN-MENU REGISTRY @@ -3317,11 +1821,6 @@ end -- ============================================================ GUI._menus = GUI._menus or {} function GUI:RegisterMenu(frame) self._menus[frame] = true end -function GUI:CloseAllMenus() - for f in pairs(self._menus) do - if f:IsShown() then f:Hide() end - end -end function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, customGet, customSet, opts) opts = opts or {} @@ -3818,26 +2317,7 @@ end local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "MONOCHROME, OUTLINE", "MONOCHROME, THICKOUTLINE" } -function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) - local options = { - NONE = L["None"], - OUTLINE = L["Outline"], - THICKOUTLINE = L["Thick Outline"], - MONOCHROME = L["Monochrome"], - ["MONOCHROME, OUTLINE"] = L["Monochrome Outline"], - ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], - _order = OUTLINE_FLAG_ORDER, - } - local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end - local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end - return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) -end -function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) - local get = function() return DF:OutlineHasShadow(dbTable[dbKey]) end - local set = function(val) dbTable[dbKey] = DF:ComposeOutline(DF:OutlineFlag(dbTable[dbKey]), val) end - return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) -end -- ============================================================ -- UNIFIED BORDER CONTROL SET @@ -3905,531 +2385,8 @@ end -- Returns the widget table (animationType, animationColor, … ) so the caller -- can merge the handles into its own control table. -- ============================================================ -function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) - opts = opts or {} - local parent = opts.parent - local fullUpdate = opts.fullUpdate or function() end - local lightUpdate = opts.lightUpdate - local lightColors = opts.lightColors - local typeLabel = opts.typeLabel or L["Border Animation"] - local excludeTypes = opts.excludeTypes -- optional set of animation-type keys to omit from the dropdown - local hideExtra = opts.hideExtra - local onTypeChange = opts.onTypeChange or function() end - local showPerfBanner = opts.perfBanner ~= false - - local function aKey(suffix) return animPrefix .. suffix end - local animTypeKey = aKey("Type") - local function animType() return dbTable[animTypeKey] or "NONE" end - local function extraOff() return (hideExtra and hideExtra()) or false end - local function animOff() return extraOff() or animType() == "NONE" end - - -- Sets of effect types each tunable applies to (truthiness on a - -- string-keyed set). Mirrors the per-effect parameter map — keep in - -- sync with StartAnimation's branches in Frames/Border.lua. - -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash - -- thickness, Inset = dash inset. - local hasFrequency = { DF_PULSATE=1, - DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } - local hasParticles = { DF_ORBIT=1, DF_PIXEL=1 } - -- CORNERS_ONLY is hidden from the type dropdown below, but keep its param - -- entries so an indicator that still carries a saved CORNERS_ONLY value shows - -- the right controls. - local hasThickness = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_PIXEL=1 } - -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which - -- modulates the border's own edges and has no separate animRect). - local hasPositioning = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } - -- Scale slider = sparkle size (DF Chase). - local hasScale = { DF_ORBIT=1 } - -- Length slider = bar length (DF Pixel's chasing bars). - local hasLength = { DF_PIXEL=1 } - local cornersOnly = { CORNERS_ONLY=1 } - local function hideUnless(set) - return function() - if animOff() then return true end - return not set[animType()] - end - end - - local w = {} - - -- All DF-owned border effects (no external glow library). The "DF " labels - -- are kept from when they sat alongside the retired LCG glows. - local animTypeOptions = { - NONE = L["None"], - DF_PULSATE = L["DF Pulsate"], - DF_ORBIT = L["DF Chase"], - DF_DASH = L["DF Dash"], - DF_FLASH = L["DF Flash"], - DF_PIXEL = L["DF Pixel"], - DF_PROC = L["DF Proc"], - BLINK = L["Blink"], - -- None first (the "off" option), then alphabetical by label. CORNERS_ONLY - -- is intentionally absent — it's kept in the engine (an existing saved - -- value still renders) but no longer offered as a pickable animation. - _order = { "NONE", "BLINK", "DF_ORBIT", - "DF_DASH", "DF_FLASH", "DF_PIXEL", "DF_PROC", "DF_PULSATE" }, - } - -- Optional caller filter: drop any excluded type from both the value map and - -- the display order (e.g. the Aura Designer border offers only the taint-safe, - -- overlay-recoverable animations — no LCG glows). - if excludeTypes then - for k in pairs(excludeTypes) do animTypeOptions[k] = nil end - local filteredOrder = {} - for _, k in ipairs(animTypeOptions._order) do - if not excludeTypes[k] then filteredOrder[#filteredOrder + 1] = k end - end - animTypeOptions._order = filteredOrder - end - w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, - animTypeOptions, - dbTable, animTypeKey, onTypeChange), 55) - -- Type dropdown respects only the extra gate (e.g. Show Border). With no - -- extra gate (Expiring override) it's always visible. - w.animationType.hideOn = hideExtra or function() return false end - - -- Perf warning: animations run an OnUpdate (or LCG internal animation) - -- per active border, which adds up in 20-30 player raids. - if showPerfBanner then - -- staticHeight ONLY where the host reflows widget WIDTHS on every layout - -- pass — i.e. the Aura Designer indicator card (its parent carries - -- dfAD_ReflowWidgets). There a self-sizing banner feeds a SetHeight -> - -- OnSizeChanged -> relayout -> SetWidth loop that drops FPS, so we predict - -- a fixed height instead. On normal settings pages the host lays out once, - -- so the banner MUST self-size to its wrapped text: a fixed height - -- overflows (text spills past the box) on narrow windows until a manual - -- drag forces a relayout. - local reflowingHost = parent and parent.dfAD_ReflowWidgets ~= nil - local perfBanner = GUI:CreateInfoBanner(parent, { - tone = "caution", - text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], - staticHeight = reflowingHost or nil, - minHeight = 56, - }) - w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) - w.animationPerfBanner.hideOn = animOff - end - - -- Animation colour applies to every effect except DF_PULSATE (which - -- modulates the border's own edge alpha — no separate colour). lightColors - -- is threaded through so AD's proxy gets live preview while dragging. - w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Color"], - dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) - w.animationColor.hideOn = function() - return animOff() or animType() == "DF_PULSATE" - end - - -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. - -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), - -- and the OnUpdate effects fall back to a sensible default period at 0. - w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], - 0, 4, 0.05, dbTable, aKey("Frequency"), - fullUpdate, lightUpdate, true), 55) - w.animationFrequency.hideOn = hideUnless(hasFrequency) - -- ⚠ This slider genuinely means different things per effect (see the comment - -- above), which is exactly why it needs saying out loud — nobody discovers - -- "0 = hold still" by dragging. - w.animationFrequency.tooltip = L["How fast the effect runs. On DF Dash this is how quickly the dashes march around the edge, and 0 holds them still. On the others it is the pulse rate, where 0 means the effect's own default speed."] - - w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], - 1, 16, 1, dbTable, aKey("Particles"), - fullUpdate, lightUpdate, true), 55) - w.animationParticles.hideOn = hideUnless(hasParticles) - w.animationParticles.tooltip = L["How many separate lights travel around the border. More reads as busier and costs a little more to draw."] - - w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], - 1, 30, 1, dbTable, aKey("Length"), - fullUpdate, lightUpdate, true), 55) - w.animationLength.hideOn = hideUnless(hasLength) - w.animationLength.tooltip = L["How long each moving segment is. Short values read as darting sparks, long ones as a sweeping tail."] - - w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], - 1, 12, 1, dbTable, aKey("Thickness"), - fullUpdate, lightUpdate, true), 55) - w.animationThickness.hideOn = hideUnless(hasThickness) - w.animationThickness.tooltip = L["How heavy the moving effect is. Separate from Border Thickness — the animation draws on its own layer, so it can be thicker or thinner than the border underneath."] - - w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], - 0.5, 3, 0.05, dbTable, aKey("Scale"), - fullUpdate, lightUpdate, true), 55) - w.animationScale.hideOn = hideUnless(hasScale) - - w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], - -50, 50, 1, dbTable, aKey("Inset"), - fullUpdate, lightUpdate, true), 55) - w.animationInset.hideOn = hideUnless(hasPositioning) - w.animationInset.tooltip = L["Moves the effect in or out from the edge, independently of the border. Push it outward to make a glow spill past the frame."] - - w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], - -50, 50, 1, dbTable, aKey("OffsetX"), - fullUpdate, lightUpdate, true), 55) - w.animationOffsetX.hideOn = hideUnless(hasPositioning) - - w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], - -50, 50, 1, dbTable, aKey("OffsetY"), - fullUpdate, lightUpdate, true), 55) - w.animationOffsetY.hideOn = hideUnless(hasPositioning) - - -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). - w.animationHideIntro = group:AddWidget(GUI:CreateCheckbox(parent, L["Hide Intro Flash"], - dbTable, aKey("ProcStart"), fullUpdate), 30) - w.animationHideIntro.hideOn = hideUnless({ DF_FLASH = 1, DF_PROC = 1 }) - w.animationHideIntro.tooltip = L["These effects open with a one-off burst before settling into their loop. Turn this on to skip the burst and go straight to the loop."] - - w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], - 2, 40, 1, dbTable, aKey("CornerLength"), - fullUpdate, lightUpdate, true), 55) - w.animationCornerLength.hideOn = hideUnless(cornersOnly) - w.animationCornerLength.tooltip = L["How far the effect runs along each edge from the corner before stopping. Small values leave four short brackets instead of a full outline."] - - return w -end -- ============================================================ -function GUI:CreateBorderControls(group, dbTable, prefix, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local fullUpdate = opts.fullUpdate or function() end - local lightUpdate = opts.lightUpdate - local lightColors = opts.lightColors - local refreshStates = opts.refreshStates - local hideWhen = opts.hideWhen - local disableWhen = opts.disableWhen - - local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 - local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 - - local function key(suffix) return prefix .. suffix end - local showKey = key("ShowBorder") - -- The Show toggle only respects the parent-level hideWhen. Everything - -- else respects hideWhen OR the Show toggle being off. - -- - -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is - -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` - -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, - -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. - -- For consumers with a different dbTable — notably Aura Designer's - -- per-aura proxy — this is the only way the visibility predicates see - -- the actual border state (e.g. proxy.BorderStyle, not the unrelated - -- DF.db.party.BorderStyle which doesn't exist). - local function hideShow() return hideWhen and hideWhen(dbTable) or false end - -- Show Border OFF no longer HIDES the border controls — they stay visible and - -- GREY OUT (disableOn = borderOff, applied by the loop at the end of this - -- function) so the panel previews them. `hideOff` now means "hidden by the - -- parent/variant gate only" (whatever the consumer passes via hideWhen); the - -- name is kept so the existing `.hideOn = hideOff` references read unchanged. - local function hideOff() return hideShow() end - local function borderOff() return dbTable[showKey] == false end - - local w = {} - - -- opts.noShowToggle: suppress the built-in "Show Border" checkbox for - -- consumers that gate the whole border on an external toggle (e.g. the - -- Targeted Spells "Highlight Important Spells" master). With the checkbox - -- gone, showKey stays nil so hideOff() reduces to hideShow() — the toolkit - -- shows/hides purely on the external hideWhen. - if not opts.noShowToggle then - w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 30) - w.show.hideOn = hideShow - end - - -- Slider label reads "Border Thickness" (more meaningful than "Size") but - -- the underlying db key stays `BorderSize` and spec.size in the - -- backend stays the same — purely a user-facing rename, no migration. - w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, - dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) - w.size.hideOn = hideOff - - -- Gradient is a STYLE, not a separate toggle. When the consumer opts into - -- gradient via include.gradient, we expose GRADIENT as a third dropdown - -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. - local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], - _order = { "SOLID", "TEXTURE" } } - if include.gradient then - styleOptions.GRADIENT = L["Gradient"] - -- Insert GRADIENT between SOLID and TEXTURE so the order reads - -- "simple colour → two colours → custom texture" in the dropdown. - styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } - end - w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], - styleOptions, dbTable, key("BorderStyle"), function() - -- Match the frame border: pick the first LSM border when switching - -- to Texture without one configured. - if dbTable[key("BorderStyle")] == "TEXTURE" then - local list = DF.GetBorderList and DF:GetBorderList() or nil - local t = dbTable[key("BorderTexture")] - if list and (not t or t == "" or t == "SOLID") then - dbTable[key("BorderTexture")] = next(list) - end - end - if refreshStates then refreshStates() end - fullUpdate() - end), 55) - w.style.hideOn = hideOff - - -- isGradient is declared up here so the Style-dependent widget cluster - -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) - -- can sit immediately below the Style dropdown — the consequence of the - -- user's style choice reads top-to-bottom without scrolling past - -- unrelated inset / offset / blend controls first. - local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end - - w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], - DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) - w.texture.hideOn = function() - return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" - end - - -- Gradient pickers — only visible under Style = GRADIENT. Grouped here - -- (between Texture and the Colour Source dropdown) so all style-dependent - -- widgets sit directly under the Style dropdown that controls them. - -- The standalone "Border Gradient" checkbox was removed when Style - -- absorbed it; Style is now the single source of truth so it's not - -- possible to pick "Solid + Class Color" then have a Gradient checkbox - -- stomp the class colour (the previous UX bug). Legacy - -- `BorderGradientEnabled = true` profiles are migrated to - -- `BorderStyle = "GRADIENT"` on db load. - if include.gradient then - local function gradHide() return hideOff() or not isGradient() end - - w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Color"], - dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) - w.gradientStart.hideOn = gradHide - w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Color"], - dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) - w.gradientEnd.hideOn = gradHide - w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], - { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, - dbTable, key("BorderGradientDirection"), fullUpdate), 55) - w.gradientDirection.hideOn = gradHide - end - - -- Colour Source dropdown sits ABOVE the colour picker so the relationship - -- "source → resulting colour" reads top-to-bottom in the panel. The - -- options table is built dynamically: Static is always present; Class - -- and Role are added if the consumer opted in via the matching include. - -- Hidden in GRADIENT style — gradient owns its own colours, no resolver - -- chain applies (see Border:BuildSpec). - local sourceKey = key("BorderColorSource") - local hasSourceDropdown = include.classColor or include.roleColor - if hasSourceDropdown then - local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } - if include.classColor then - sourceOptions.CLASS = L["Class"] - sourceOptions._order[#sourceOptions._order + 1] = "CLASS" - end - if include.roleColor then - sourceOptions.ROLE = L["Role"] - sourceOptions._order[#sourceOptions._order + 1] = "ROLE" - end - -- Default the source from the legacy boolean keys when first opened. - if dbTable[sourceKey] == nil then - if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" - elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" - else dbTable[sourceKey] = "STATIC" end - end - w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], - sourceOptions, dbTable, sourceKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 55) - w.colorSource.hideOn = function() return hideOff() or isGradient() end - w.colorSource.tooltip = L["Where the border colour comes from. Static uses the colour below; Class and Role read it from the unit, so the border tells you who you are looking at without reading the name."] - end - - -- Static colour picker — only visible when source is STATIC (or when the - -- consumer didn't enable any resolver at all, so source doesn't exist). - -- Hidden in GRADIENT style (gradient uses its own start/end pickers). - w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), - true, fullUpdate, lightColors, lightColors ~= nil), 35) - w.color.hideOn = function() - if hideOff() or isGradient() then return true end - if hasSourceDropdown then - local src = dbTable[sourceKey] or "STATIC" - return src ~= "STATIC" - end - return false - end - - -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes - -- the SAME alpha component the colour picker exposes - -- (BorderColor.a), so the slider is just a convenient handle for - -- the picker's alpha bar — no separate alpha key to migrate or keep in - -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the - -- two gradient pickers each carry their own alpha, and a single slider - -- has no obvious meaning). - if include.alpha then - -- Ensure the underlying colour table has an alpha component so the - -- slider doesn't read nil on first open. The picker also seeds .a but - -- we don't depend on widget-creation order. - local c = dbTable[key("BorderColor")] - if type(c) ~= "table" then - c = { r = 0, g = 0, b = 0, a = 1 } - dbTable[key("BorderColor")] = c - end - if c.a == nil then c.a = 1 end - - -- Read-time nil-guard: these closures fire on the slider's OnShow at - -- arbitrary later times (tab/page re-show, mode switch), NOT just at - -- creation. The seed above only guarantees the table exists NOW — a proxy - -- dbTable can resolve BorderColor to nil later (e.g. re-showing the AD page - -- for a mode whose config doesn't surface the key), so re-read and guard - -- each call instead of assuming the table is still there. - w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, - nil, nil, fullUpdate, lightColors or lightUpdate, true, - function() - local bc = dbTable[key("BorderColor")] - return (bc and bc.a) or 1 - end, - function(v) - local bc = dbTable[key("BorderColor")] - if bc then bc.a = v end - end), 55) - w.alpha.hideOn = function() return hideOff() or isGradient() end - end - - if include.inset then - w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, - dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) - w.inset.hideOn = hideOff - -- Thickness / Inset / Offset are three similar-sounding sliders that do - -- different things; the tooltip lives here because Inset is the one - -- nobody guesses. - w.inset.tooltip = L["Pulls the border inward (positive) or pushes it outward (negative) from the edge. Thickness is how heavy the line is, Inset is how far in it sits, Offset slides the whole border sideways."] - end - - if include.offset then - w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, - dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) - w.offsetX.hideOn = hideOff - w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, - dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) - w.offsetY.hideOn = hideOff - -- No tooltip on Offset X/Y, deliberately, and the same goes for every - -- other Offset slider in the addon (~60 of them): an offset is a well - -- understood control and a tooltip restating it is noise. Inset is the - -- one that needs explaining, so the Thickness / Inset / Offset - -- distinction is spelled out THERE, once. Krathe's call, 2026-07-27 — - -- these two briefly had tooltips and Border Shadow's offsets did not, - -- which is the inconsistency that prompted it. - end - - if include.blendMode then - w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], - { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"], DISABLE = L["Disable"] }, - dbTable, key("BorderBlendMode"), fullUpdate), 55) - w.blendMode.hideOn = hideOff - w.blendMode.tooltip = L["How the border colour mixes with whatever is behind it. Blend is normal. Add brightens and is what makes a colour glow. Modulate darkens. Disable ignores opacity entirely and draws the colour flat."] - end - - if include.shadow then - local shadowOnKey = key("BorderShadowEnabled") - w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() - if refreshStates then refreshStates() end - fullUpdate() - end), 30) - w.shadowEnabled.hideOn = hideOff - -- Border Shadow OFF greys (not hides) its sub-controls — a nested boolean - -- toggle, same grey-everything rule. The end-of-function loop OR-composes - -- borderOff, so these also grey when Show Border is off. - local function shadowOff() return dbTable[shadowOnKey] == false end - - w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Color"], - dbTable, key("BorderShadowColor"), true, fullUpdate), 35) - w.shadowColor.hideOn = hideOff - w.shadowColor.disableOn = shadowOff - w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, - dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) - w.shadowSize.hideOn = hideOff - w.shadowSize.disableOn = shadowOff - w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, - dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) - w.shadowOffsetX.hideOn = hideOff - w.shadowOffsetX.disableOn = shadowOff - w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, - dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) - w.shadowOffsetY.hideOn = hideOff - w.shadowOffsetY.disableOn = shadowOff - end - - -- ===== Animation (Stage 3) ===== - -- include.animate drops the full Border Animation control set (Type - -- dropdown + per-effect tunables, each with a hideOn keyed to the effect - -- it applies to). Built from the shared GUI:CreateAnimationControls so the - -- base panel and AD's Expiring override never drift. The whole block folds - -- under Show Border via hideExtra = hideOff. Widget handles are merged back - -- onto `w` so existing references (w.animationType, …) are preserved. - if include.animate then - local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { - parent = parent, - fullUpdate = fullUpdate, - lightUpdate = lightUpdate, - lightColors = lightColors, - typeLabel = L["Border Animation"], - -- Optional caller filter, forwarded from the CreateBorderControls call - -- site (e.g. the Aura Designer border restricts to overlay-recoverable - -- animation types). nil for every other caller → full type list. - excludeTypes = opts.animExcludeTypes, - hideExtra = hideOff, - onTypeChange = function() - if refreshStates then refreshStates() end - fullUpdate() - end, - }) - for k, v in pairs(aw) do w[k] = v end - end - - -- ===== Colour resolver toggles (Stage 2) ===== - -- These flip BorderColor's source from the static picker to a per-unit / - -- per-aura / per-tick computation. BuildSpec applies them in priority - -- order (type > time > class > role > static) when the consumer passes - -- ctx to BuildSpec. The static colour picker still controls the fallback - -- (when ctx is missing or the resolver yields nil). - - -- (Colour Source dropdown + Static colour picker + Alpha slider are wired - -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the - -- relationship "source → colour" reads top-to-bottom in the panel.) - - if include.colorByTime then - w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) - w.colorByTime.hideOn = hideOff - -- The actual colour curve picker is consumer-specific (e.g. AD's - -- existing expiring colour curve) and is added by the consumer - -- alongside this checkbox. - end - - if include.colorByType then - w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) - w.colorByType.hideOn = hideOff - end - - -- Two independent greys, both composed on top of whatever disableOn a control - -- already carries (e.g. the shadow sub-controls), and both leaving the - -- variant hideOn untouched: - -- disableWhen — the CONSUMER's gate: the feature this border belongs to is - -- switched off. Applies to EVERY widget including the Show Border - -- checkbox, since with the feature off there is nothing for it to show. - -- borderOff — Show Border itself is off. Applies to everything EXCEPT the - -- Show Border checkbox, which has to stay clickable to turn it back on. - -- RefreshChildStates applies disableOn to group children, and CreateCheckbox - -- auto-refreshes on toggle, so both greys update live. - for k, widget in pairs(w) do - if type(widget) == "table" and widget.SetEnabled then - local prev = widget.disableOn - local isShow = (k == "show") - widget.disableOn = function(d) - if disableWhen and disableWhen(dbTable) then return true end - if not isShow and borderOff() then return true end - return (prev and prev(d)) or false - end - end - end - - return w -end -- ============================================================ -- SHARED TEXT-STYLE CONTROLS (pairs with DF.TextStyle — the engine consumers @@ -4452,72 +2409,14 @@ end -- Returns the created widgets keyed { font, scale, outline, shadow, color, anchor, -- offsetX, offsetY, justifyH, justifyV } so pages can attach extra gates. -- ============================================================ -function GUI:CreateTextControls(group, dbTable, prefix, opts) - opts = opts or {} - local parent = opts.parent - local include = opts.include or {} - local onChange = opts.onChange - local onDrag = opts.onDrag or onChange - local L = DF.L - - local scaleMin, scaleMax, scaleStep = opts.scaleMin or 0.5, opts.scaleMax or 2.0, opts.scaleStep or 0.05 - local offMin, offMax = opts.offsetMin or -150, opts.offsetMax or 150 - - local function key(suffix) return prefix .. suffix end - local widgets = {} - - -- Apply the shared page gates to a widget, composing with any the widget factory set. - local function gate(w) - if opts.disableOn then - local prev = w.disableOn - w.disableOn = function(d) return (opts.disableOn(d) or (prev and prev(d))) and true or false end - end - if opts.hideOn then w.hideOn = opts.hideOn end - return w - end - - widgets.font = gate(group:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], dbTable, key("Font"), onChange), 55)) - widgets.scale = gate(group:AddWidget(GUI:CreateSlider(parent, L["Scale"], scaleMin, scaleMax, scaleStep, dbTable, key("Scale"), nil, onDrag, true), 55)) - widgets.outline = gate(group:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], dbTable, key("Outline"), onChange), 55)) - widgets.shadow = gate(group:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], dbTable, key("Outline"), onChange), 30)) - - if include.color then - widgets.color = gate(group:AddWidget(GUI:CreateColorPicker(parent, opts.colorLabel or L["Text Color"], dbTable, key("Color"), false, onChange, onDrag, true), 35)) - if opts.colorDisableOn then - local prev = widgets.color.disableOn - widgets.color.disableOn = function(d) return (opts.colorDisableOn(d) or (prev and prev(d))) and true or false end - end - end - - if include.anchor ~= false then - local anchorOptions = { - CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], - TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], - } - widgets.anchor = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], anchorOptions, dbTable, key("Anchor"), onChange), 55)) - -- Anchor vs Justify is the pair people get wrong: one places the text, - -- the other arranges it within its own box. Both say so, from their side. - widgets.anchor.tooltip = L["Which part of the element the text is pinned to. Offset X and Y then nudge it from there."] - end - if include.offsets ~= false then - widgets.offsetX = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], offMin, offMax, 1, dbTable, key("X"), nil, onDrag, true), 55)) - widgets.offsetY = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], offMin, offMax, 1, dbTable, key("Y"), nil, onDrag, true), 55)) - end - - -- Justify is OPT-IN (include.justify = true). It's redundant with Anchor for short - -- single-token text on a small icon (duration/stacks) and boxing to justify TRUNCATES - -- wide text like "59m" — so the aura pages don't expose it. The DF.TextStyle engine - -- still honors JustifyH/JustifyV keys for a future wide/fixed-region consumer. - if include.justify then - local justifyHOptions = { [""] = L["Default"], LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } - local justifyVOptions = { [""] = L["Default"], TOP = L["Top"], MIDDLE = L["Middle"], BOTTOM = L["Bottom"] } - widgets.justifyH = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify H"], justifyHOptions, dbTable, key("JustifyH"), onChange), 55)) - widgets.justifyH.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] - widgets.justifyV = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify V"], justifyVOptions, dbTable, key("JustifyV"), onChange), 55)) - widgets.justifyV.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] - end - - return widgets -end - \ No newline at end of file +-- ============================================================ +-- SHARED WITH THE SETTINGS-PANEL WIDGETS +-- ============================================================ +-- ☠ Published on _priv rather than aliased from a public GUI.X name: these +-- are file locals with no public counterpart, and where a public name does +-- exist it can be a method WRAPPER rather than the local itself. Aliasing the +-- wrapper is what broke CreateElementBackdrop in game once already. +P.ConfirmDeletePreset = ConfirmDeletePreset +P.PromptPresetName = PromptPresetName +P.OUTLINE_FLAG_ORDER = OUTLINE_FLAG_ORDER diff --git a/DandersFrames_Options/DandersFrames_Options.toc b/DandersFrames_Options/DandersFrames_Options.toc index b9e00a07..a213d637 100644 --- a/DandersFrames_Options/DandersFrames_Options.toc +++ b/DandersFrames_Options/DandersFrames_Options.toc @@ -30,6 +30,9 @@ # GUI/Pages/Options first (heads the page chain) # GUI parts used only by settings pages +# Widget factories the live addon never calls, lifted out of the resident +# GUI.lua / Widgets.lua. Loads first: the parts below build pages with them. +GUI\SettingsWidgets.lua GUI\Sections.lua GUI\Controls.lua GUI\Panel.lua diff --git a/DandersFrames_Options/GUI/SettingsWidgets.lua b/DandersFrames_Options/GUI/SettingsWidgets.lua new file mode 100644 index 00000000..998e7009 --- /dev/null +++ b/DandersFrames_Options/GUI/SettingsWidgets.lua @@ -0,0 +1,2545 @@ +-- ============================================================ +-- SETTINGS-PANEL WIDGETS +-- ============================================================ +-- Widget factories used only by the settings panel, lifted out of the resident +-- GUI toolkit so they are neither parsed nor held in memory for players who +-- never open it. GUI.lua and Widgets.lua keep everything the live addon uses -- +-- the movers, the search box, tooltips and the styling primitives. +-- +-- ☠ Nothing the resident toolkit calls may live here, including calls from its +-- own file-local helpers -- that is subtler than it sounds and cost five +-- functions a pull-back when this file was built. lod_gate_check.py guards the +-- main addon; alias_check.py checks the aliases below still resolve. +local DF = DandersFrames +local GUI = DF.GUI +local L = DF.L +local P = GUI._priv +-- Aliases of objects the toolkit created; they add no state. +local C_PANEL, C_ELEMENT, C_BORDER, C_HOVER, C_TEXT, C_TEXT_DIM = + GUI.Colors.panel, GUI.Colors.element, GUI.Colors.border, GUI.Colors.hover, GUI.Colors.text, GUI.Colors.textDim +local GetThemeColor = GUI.GetThemeColor +local SnapLen = GUI.SnapLen +local AddOverrideIndicators = P.AddOverrideIndicators +local CreateElementBackdrop = P.CreateElementBackdrop +local CreatePanelBackdrop = P.CreatePanelBackdrop +local ConfirmDeletePreset = P.ConfirmDeletePreset +local PromptPresetName = P.PromptPresetName +local OUTLINE_FLAG_ORDER = P.OUTLINE_FLAG_ORDER +local LayoutPixelBorder = P.LayoutPixelBorder +local pixelBordered = P.pixelBordered + +-- ---- from GUI.lua ---- +-- Sync a widget's slot height into its host SettingsGroup and re-flow. Used by any +-- widget that only learns its true height AFTER construction (a measured label, an +-- info banner): update the group's stored entry, re-lay out the group, then bubble to +-- the page so sibling groups in the same column re-anchor to the group's new bottom. +-- Without the bubble a grown group's backdrop overshoots the next group's anchor and +-- renders as an empty rectangle of backdrop above it. +function GUI:RelayoutHost(widget, slotHeight) + if not widget then return end + local g = widget.settingsGroup + -- RETIRED widgets must not re-flow anything. A measured label arms a next-frame + -- converge; if the page rebuilds first (any Refresh — e.g. flipping the Colours + -- page's Seconds/Percent tabs), that timer still fires against the PREVIOUS build. + -- Its group is by then parented to the trash frame with its anchors cleared, so + -- re-laying it out sizes children off a dead frame, and the parent walk below still + -- reaches the LIVE page and makes it re-lay out mid-flight. IsShown() does not catch + -- this — a frame keeps its own shown flag when an ancestor is hidden — so test the + -- ancestry instead. + local trash = GUI._trashFrame + if trash then + local p = (g or widget) + while p do + if p == trash then return end + p = p:GetParent() + end + end + if g and g.LayoutChildren then + for _, entry in ipairs(g.groupChildren or {}) do + if entry.widget == widget then + entry.height = slotHeight + break + end + end + g:LayoutChildren() + end + local p = (g or widget):GetParent() + while p do + if type(p.RefreshStates) == "function" and p.children then + p:RefreshStates() + return + end + p = p:GetParent() + end +end + +-- Add a gold "New" badge to the right of a section header's text. Returns the +-- badge FontString, or nil if the section isn't registered in NewSections or +-- has already been marked seen. The badge clears (and is persisted as seen) +-- the next time the user navigates away from `tabName`. +function GUI:AddSectionNewBadge(widget, tabName, sectionId) + -- Anchor to whichever label FontString the widget exposes: + -- * CreateHeader containers use `.text` + -- * CreateDropdown containers use `.label` + local anchor = widget and (widget.text or widget.label) + if not anchor or not tabName or not sectionId then return end + local key = tabName .. "." .. sectionId + if not GUI.NewSections[key] then return end + + local seen = DandersFramesDB_v2 and DandersFramesDB_v2.seenSections + and DandersFramesDB_v2.seenSections[key] + if seen then return end + + local badge = widget:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + badge:SetPoint("LEFT", anchor, "RIGHT", 6, 0) + badge:SetText(L["New"]) + badge:SetTextColor(1, 0.82, 0) + + GUI.pendingSectionBadges[tabName] = GUI.pendingSectionBadges[tabName] or {} + GUI.pendingSectionBadges[tabName][key] = badge + return badge +end + +-- Returns true if the given tab should be disabled for the currently +-- selected mode (i.e. the tab is mode-specific and that mode is off). +function GUI:IsTabDisabledForCurrentMode(tabName) + if not tabName then return false end + if GUI.AlwaysAccessiblePages[tabName] then return false end + if GUI.SelectedMode == "party" and DF.db and DF.db.partyEnabled == false then return true end + if GUI.SelectedMode == "raid" and DF.db and DF.db.raidEnabled == false then return true end + return false +end + +-- Walk all registered tabs and update their .disabled flag + visuals +-- based on the current mode and enable flags. Call after mode switches. +function GUI:UpdateTabAvailability() + if not GUI.Tabs then return end + for name, btn in pairs(GUI.Tabs) do + local disabled = GUI:IsTabDisabledForCurrentMode(name) + btn.disabled = disabled + if btn.Text then + if disabled then + btn.Text:SetTextColor(0.45, 0.45, 0.45) + else + btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + end + end + if disabled and not btn.isActive then + btn:SetBackdropColor(0, 0, 0, 0) + end + end + + -- Refresh the sidebar so party-only tabs (e.g. Visibility) hide/show for + -- the current mode. + if GUI.UpdateTabLayout then GUI:UpdateTabLayout() end + + -- If the active tab just became hidden (party-only while in raid), move to a + -- safe always-present tab so the user isn't left on a hidden/empty page. + if not GUI._redirectingTab and GUI.SelectedMode == "raid" and GUI.CurrentPageName then + local cur = GUI.Tabs[GUI.CurrentPageName] + if cur and cur.partyOnly and GUI.SelectTab then + GUI._redirectingTab = true + GUI.SelectTab("general_settings") + GUI._redirectingTab = false + end + end +end + +-- Re-derive thickness on a scale change. Rides the same sweep that already +-- re-derives backdrop edge widths, so there is no new per-frame work. +function GUI:RefreshPixelBorders() + for frame in pairs(pixelBordered) do + if frame:IsVisible() then LayoutPixelBorder(frame) end + end +end + +function GUI:CreateHeader(parent, text) + -- Use a frame container so we can position text at bottom (padding above) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(200, 25) + container:Show() + container.rowKind = "header" + -- Factory-owned slot, and fixed so ResolveRowHeight ignores whatever the call + -- site passes. Headers were handed 25 in collapsible groups and 40 in plain + -- ones -- the same widget, two rhythms, across ~200 sites. Owning it here + -- unifies them without touching any of those call sites, which is exactly the + -- rule the other factory rows already follow (see GUI.RowHeight). + container.preferredHeight = GUI.RowHeight.sectionHeader + container.fixedRowHeight = true + + local h = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") + h:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 0, 2) + h:SetText(text) + local c = GetThemeColor() + h:SetTextColor(c.r, c.g, c.b) + h:SetJustifyH("LEFT") + h.UpdateTheme = function() local nc = GetThemeColor() h:SetTextColor(nc.r, nc.g, nc.b) end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, h) + + -- Store text reference + container.text = h + + -- Forward IsShown to ensure layout works + container.GetText = function() return h:GetText() end + + -- SEARCH: Track current section + if DF.Search then + DF.Search:SetCurrentSection(text) + end + + return container +end + +-- Collapsible section for grouping related settings. +-- Collapsed state is persisted in DandersFramesDB_v2.collapsedGroups keyed by +-- `text` (shared store with CreateSettingsGroup's collapsible header), so the +-- user's fold preference survives reloads. +function GUI:CreateCollapsibleSection(parent, text, defaultExpanded, width) + local section = CreateFrame("Frame", nil, parent, "BackdropTemplate") + section:SetSize(width or 500, 28) -- Header height + -- Resolve initial expanded state: SavedVariables override the default. + local savedStates = GUI:GetCollapsedGroups() + if text and savedStates[text] ~= nil then + section.expanded = not savedStates[text] + else + section.expanded = defaultExpanded ~= false + end + section.sectionTitleText = text + section.sectionChildren = {} + -- Same contract as CreateHeader's container.GetText: it is how a section is FOUND + -- by title (Search:ScrollToSection, and every GUI:LinkToSetting{ section = ... } + -- cross-link through it). Without it a collapsible section is invisible to those + -- lookups, so a link to it silently does nothing — which is exactly what happened + -- to the Colours page's "Color by Time" links when that box was promoted from a + -- plain header to a collapsible section. + section.GetText = function(self) return self.sectionTitleText end + section.paddingAfter = 8 -- Padding space after header before first child + + -- Header bar with background. Same look as before, via the shared backdrop + -- helper rather than a private copy of it, so it picks up the pixel border + -- (and anything else that lands there) without its own wiring. + GUI:CreateElementBackdrop(section, { + bgColor = { r = C_PANEL.r, g = C_PANEL.g, b = C_PANEL.b, a = 0.8 }, + borderColor = { r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5 }, + }) + + -- Click area + local clickArea = CreateFrame("Button", nil, section) + clickArea:SetAllPoints() + clickArea:EnableMouse(true) + + -- Expand/collapse arrow icon + section.arrow = section:CreateTexture(nil, "OVERLAY") + section.arrow:SetPoint("LEFT", 8, 0) + section.arrow:SetSize(12, 12) + if section.expanded then + section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + else + section.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") + end + section.arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + -- Section title + section.title = section:CreateFontString(nil, "OVERLAY", "DFFontNormal") + section.title:SetPoint("LEFT", 26, 0) + section.title:SetText(text) + local c = GetThemeColor() + section.title:SetTextColor(c.r, c.g, c.b) + section.title.UpdateTheme = function() + if section.previewDimmed then + section.title:SetTextColor(0.5, 0.5, 0.5) + else + local nc = GetThemeColor() + section.title:SetTextColor(nc.r, nc.g, nc.b) + end + end + -- Grey the header title when the section's feature is disabled (driven by + -- the preview wiring). Routes through UpdateTheme so theme changes respect it. + section.SetPreviewDimmed = function(self, dimmed) + self.previewDimmed = dimmed and true or false + self.title.UpdateTheme() + end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, section.title) + + -- Optional inline tag — small yellow text placed after the title to + -- stand out as a status summary (e.g. "[Normal Dispels]"). Call + -- section:SetTag(text) at any time; pass nil or empty string to clear. + section.tag = section:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + section.tag:SetPoint("LEFT", section.title, "RIGHT", 8, 0) + section.tag:SetTextColor(1, 0.82, 0, 1) -- WoW standard gold/yellow + section.tag:SetText("") + section.SetTag = function(self, text) + if text and text ~= "" then + self.tag:SetText(text) + self.tag:Show() + else + self.tag:SetText("") + self.tag:Hide() + end + end + + -- SEARCH: Track current section + if DF.Search then + DF.Search:SetCurrentSection(text) + end + + -- Toggle function + section.Toggle = function(self) + self.expanded = not self.expanded + if self.expanded then + self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + else + self.arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right") + end + -- Persist collapsed state to SavedVariables (only store true, remove when expanded) + if self.sectionTitleText then + local saved = GUI:GetCollapsedGroups() + saved[self.sectionTitleText] = (not self.expanded) or nil + end + + -- Trigger layout refresh (RefreshStates handles show/hide based on expanded state) + if parent.RefreshStates then + parent:RefreshStates() + end + end + + -- Register child widgets to this section + section.RegisterChild = function(self, widget) + table.insert(self.sectionChildren, widget) + widget.parentSection = self + + -- Use a marker to check section state during RefreshStates + widget.collapsibleSection = self + end + + -- Optional header preview thumbnails — a right-aligned row of small icon + -- swatches on the header bar, used to show the actual icon(s) a section + -- controls (e.g. the Role Icon section previews the Tank/Healer/DPS icons in + -- the currently selected style). Always visible on the header, so the page + -- reads as a gallery whether sections are expanded or collapsed. + -- + -- icons: array of entries, each EITHER an icon or a text label: + -- { texture = "atlas-or-path", coords = {l,r,t,b}?, desaturate = bool? } + -- { text = "MT", desaturate = bool? } + -- Icon entries are fixed-width swatches; text entries are sized to the + -- string. Entries flow right-to-left from the header's right edge so the + -- first entry sits leftmost. nil/empty clears the preview. + section.previewIcons = {} + section.SetPreviewIcons = function(self, icons) + local pool = self.previewIcons + local n = icons and #icons or 0 + local SIZE, GAP, RIGHT_INSET = 18, 4, -10 + local x = RIGHT_INSET + for i = n, 1, -1 do -- right-to-left so entry 1 ends up leftmost + local data = icons[i] + local slot = pool[i] + if not slot then + slot = CreateFrame("Frame", nil, self) + slot:SetHeight(SIZE) + slot.tex = slot:CreateTexture(nil, "OVERLAY") + slot.tex:SetAllPoints() + slot.fs = slot:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + slot.fs:SetAllPoints() + slot.fs:SetJustifyH("CENTER") + pool[i] = slot + end + local dim = data.desaturate and true or false + local w = SIZE + if data.text and data.text ~= "" then + slot.tex:Hide() + slot.fs:SetText(data.text) + if dim then + slot.fs:SetTextColor(0.5, 0.5, 0.5, 1) + elseif data.color then + slot.fs:SetTextColor(data.color.r or 1, data.color.g or 1, data.color.b or 1, data.color.a or 1) + else + slot.fs:SetTextColor(1, 0.82, 0, 1) + end + slot.fs:Show() + w = math.max(SIZE, (slot.fs:GetStringWidth() or 0) + 4) + else + slot.fs:Hide() + -- data.texture may be an atlas name or a texture path; the helper + -- prefers the atlas and falls back to the path (+ optional coords). + local co = data.coords + DF:SetIconTextureOrAtlas(slot.tex, data.texture, co and co[1], co and co[2], co and co[3], co and co[4]) + slot.tex:SetDesaturated(dim) + -- Optional per-entry inset: textures that fill their cell edge-to-edge + -- (e.g. raid-target markers) read bigger than the padded status-icon + -- atlases. data.inset shrinks the swatch to match. + local pad = data.inset or 0 + slot.tex:ClearAllPoints() + slot.tex:SetPoint("TOPLEFT", slot, "TOPLEFT", pad, -pad) + slot.tex:SetPoint("BOTTOMRIGHT", slot, "BOTTOMRIGHT", -pad, pad) + slot.tex:Show() + end + slot:SetWidth(w) + slot:ClearAllPoints() + slot:SetPoint("RIGHT", self, "RIGHT", x, 0) + slot:Show() + x = x - w - GAP + end + for i = n + 1, #pool do pool[i]:Hide() end + end + + -- Hover effects + clickArea:SetScript("OnEnter", function() + section:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.8) + end) + clickArea:SetScript("OnLeave", function() + section:SetBackdropColor(C_PANEL.r, C_PANEL.g, C_PANEL.b, 0.8) + end) + clickArea:SetScript("OnClick", function() + section:Toggle() + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + + return section +end + +-- Collapsed state persistence (stored in SavedVariables, survives logout) +-- Lazily initialized from DandersFramesDB_v2.collapsedGroups on first access +function GUI:GetCollapsedGroups() + if not DandersFramesDB_v2 then return {} end + if not DandersFramesDB_v2.collapsedGroups then + DandersFramesDB_v2.collapsedGroups = {} + end + return DandersFramesDB_v2.collapsedGroups +end + +-- ---- from Widgets.lua ---- +function GUI:CreateButton(parent, text, width, height, func, iconName) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + local opts = { width = width or 120, height = height or 22, text = text } + -- Optional leading icon by Media\Icons name (14px to suit the small buttons). + if iconName then + opts.icon = { texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, size = 14 } + end + GUI:StyleButton(btn, opts) + btn:SetScript("OnClick", function(self) + if func then func(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + return btn +end + +-- Standard close/dismiss button: a small square danger-toned button showing a +-- "×" glyph. Replaces the many hand-rolled red close buttons on dialogs/panels. +-- opts = { size (20), onClick, tooltip, tone }. +-- tone = nil → dim grey "×" at rest → white on hover (close/dismiss; default) +-- tone = "danger" → RED "×" at rest → brighter red on hover (inline destructive +-- removes: list-item / tag removes). Both keep the red hover wash. +-- A horizontal row of buttons, chained left-to-right with one gap, sized as a +-- single layout slot. Pages were building this by hand every time -- a bare +-- CreateFrame, then SetPoint("LEFT", prev, "RIGHT", 6, 0) per button, plus the +-- HookScript/ShowTooltip pair on any button that needed a tooltip. +-- +-- buttons = { { label, width, onClick, icon, tooltip, key }, ... } +-- tooltip is a ShowTooltip spec ({title, lines, tone}); a button IS its own +-- label, so it takes the whole-widget hover rather than AttachTooltip's +-- label-only hit area (AttachTooltip returns early without a label region). +-- key names the button on row.buttons for a caller that needs it later. +function GUI:CreateButtonRow(parent, buttons, opts) + opts = opts or {} + local gap, h = opts.gap or 6, opts.height or 24 + local row = CreateFrame("Frame", nil, parent) + row:SetSize(opts.width or 540, opts.rowHeight or (h + 4)) + row.buttons = {} + local prev + for i, spec in ipairs(buttons) do + local btn = GUI:CreateButton(row, spec.label, spec.width or 80, h, spec.onClick, spec.icon) + if prev then + btn:SetPoint("LEFT", prev, "RIGHT", gap, 0) + else + btn:SetPoint("LEFT", 0, 0) + end + if spec.tooltip then + btn:HookScript("OnEnter", function(self) GUI:ShowTooltip(self, spec.tooltip) end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + row.buttons[spec.key or i] = btn + prev = btn + end + return row +end + +function GUI:CreateDesignerPresetBar(parent, opts) + opts = opts or {} + local kind = opts.kind or "aura" + local getMode = opts.getMode or function() return "party" end + local onChange = opts.onChange or function() end + + local bar = CreateFrame("Frame", nil, parent) + bar:SetHeight(24) + + local label = bar:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("LEFT", 0, 0) + label:SetText(L["Template:"]) + label:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local function CurrentName() + return DF:GetModeDesignerPresetName(kind, getMode()) + end + + -- True while editing a raid auto-layout (the only context with an "inherit + -- the global preset" choice — normal party/raid modes ARE the base). + -- Mode-gated: auto-layouts are RAID-only, but the GUI can be reopened on + -- the party tab while editing (ToggleGUI re-derives SelectedMode) — the + -- PARTY preset bar must not show layout state, and its "Inherit (Global)" + -- click must never clear the RAID layout's override. + local function IsEditingLayout() + return getMode() == "raid" + and DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing() + end + + -- The label to show on the dropdown button: "Inherit (Global)" when the + -- edited layout has no override, otherwise the resolved preset name. + local function CurrentLabel() + if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then + return L["Inherit (Global)"] + end + return CurrentName() + end + + -- Dropdown button + menu (rebuilt on each open so it always reflects the lib) + local ddBtn = CreateFrame("Button", nil, bar, "BackdropTemplate") + ddBtn:SetSize(150, 22) + ddBtn:SetPoint("LEFT", label, "RIGHT", 6, 0) + CreateElementBackdrop(ddBtn) + ddBtn.text = ddBtn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + ddBtn.text:SetPoint("LEFT", 6, 0) + ddBtn.text:SetPoint("RIGHT", -16, 0) + ddBtn.text:SetJustifyH("LEFT") + ddBtn.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local arrow = ddBtn:CreateTexture(nil, "OVERLAY") + arrow:SetPoint("RIGHT", -4, 0) + arrow:SetSize(10, 10) + arrow:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\expand_more") + arrow:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + local menu = CreateFrame("Frame", nil, ddBtn, "BackdropTemplate") + menu:SetFrameStrata("FULLSCREEN_DIALOG") + GUI:RegisterMenu(menu) + menu:SetPoint("TOPLEFT", ddBtn, "BOTTOMLEFT", 0, -1) + menu:SetWidth(150) + CreatePanelBackdrop(menu) + menu:Hide() + + -- Row pool: frames can't be garbage-collected in WoW, so recreating the + -- items on every open (the old Hide+SetParent(nil) approach) leaked a row + -- set per click. Reuse instead. + local menuRows = {} + local function BuildMenu() + for _, row in ipairs(menuRows) do row:Hide() end + local used = 0 + local y = -4 + local function AddItem(label, onClick) + used = used + 1 + local item = menuRows[used] + if not item then + item = CreateFrame("Button", nil, menu) + item:SetHeight(20) + item.text = item:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + item.text:SetPoint("LEFT", 4, 0) + item:SetScript("OnEnter", function(s) s.text:SetTextColor(1, 1, 1) end) + item:SetScript("OnLeave", function(s) s.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end) + item:SetScript("OnClick", function(s) + s.onClick() + menu:Hide() + bar:Refresh() + onChange() + end) + menuRows[used] = item + end + item:ClearAllPoints() + item:SetPoint("TOPLEFT", 4, y) + item:SetPoint("TOPRIGHT", -4, y) + item.text:SetText(label) + item.text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + item.onClick = onClick + item:Show() + y = y - 20 + end + -- "Inherit (Global)" — only while editing a raid auto-layout. Clears the + -- layout's preset override so it follows your global preset. + if IsEditingLayout() then + AddItem(L["Inherit (Global)"], function() + if DF.InheritLayoutDesignerPreset then DF:InheritLayoutDesignerPreset(kind) end + end) + end + for _, name in ipairs(DF:ListDesignerPresets(kind)) do + AddItem(name, function() DF:SetModeDesignerPreset(kind, getMode(), name) end) + end + menu:SetHeight(-y + 4) + end + ddBtn:SetScript("OnClick", function() + if menu:IsShown() then menu:Hide() else BuildMenu(); menu:Show() end + end) + + -- SHARING MARKER. A template can be pointed at by the other mode, a pinned + -- set or an auto layout, and editing it then changes every one of them — + -- which nothing on this bar used to say. The dropdown is a fixed 150px, so + -- the FACT rides as a glyph and the NAMES go in the tooltip, which is free. + -- + -- Deliberately NOT clickable. Splitting a shared template off for this mode + -- is exactly what Duplicate does, two buttons to the right, and Duplicate + -- also lets you name the copy. + local shareIcon = ddBtn:CreateTexture(nil, "OVERLAY") + shareIcon:SetSize(12, 12) + shareIcon:SetPoint("RIGHT", arrow, "LEFT", -3, 0) + shareIcon:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\sync") + shareIcon:SetVertexColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + shareIcon:Hide() + + -- Read off the REFS, not the party/raid sync flag: sharing by hand (picking + -- the other mode's preset from this dropdown) counts exactly the same, and a + -- ref can't fall out of step with itself. + local function SharedWith() + if IsEditingLayout() and DF.IsLayoutDesignerInheriting and DF:IsLayoutDesignerInheriting(kind) then + return {} -- inheriting: this bar isn't sitting on a preset of its own + end + return (DF.ListDesignerPresetUsers and DF:ListDesignerPresetUsers(kind, CurrentName(), getMode())) or {} + end + + -- The consumers (Party/Raid, Auto Layouts, Pinned Frames) are spread across + -- three other pages, so naming them is the one thing this tooltip has to do + -- — the bar itself already shows what a template is. "can" holds for all + -- four: Auto Layouts and Pinned Frames may inherit their mode's instead + -- (a nil ref), and Party/Raid resolve to a default when nothing is set. + -- Names match their page titles so they are findable. + ddBtn:SetScript("OnEnter", function(self) + local lines = { + L["Templates can be used by Party, Raid, Auto Layouts and Pinned Frames."], + } + local shared = SharedWith() + if #shared > 0 then + lines[#lines + 1] = " " + lines[#lines + 1] = { text = format(L["Also used by: %s"], table.concat(shared, ", ")), accent = true } + -- "there" points back at the list above, so the consequence needs no + -- nouns of its own. It has to be said: a shared template's edits + -- reach a screen you are not looking at, and naming the users is + -- only the fact, not the warning. + lines[#lines + 1] = L["Edits apply there too."] + end + GUI:ShowTooltip(self, { title = L["Templates"], lines = lines }) + end) + ddBtn:SetScript("OnLeave", function() GUI:HideTooltip() end) + + -- When editing a raid auto-layout, default the NEW preset name to the + -- layout's name (e.g. editing "31-40" → prefill "31-40") so making a + -- per-layout preset is one click + Enter. nil (blank) otherwise. (Duplicate + -- names after its source preset, not the layout.) + local function EditingLayoutName() + if not IsEditingLayout() then return nil end -- mode-gated (raid only) + local apu = DF.AutoProfilesUI + if apu and apu.editingProfile then + return apu.editingProfile.name + end + return nil + end + + -- Action buttons. opts.iconButtons = true swaps the labeled buttons for + -- compact tooltipped icon-only buttons (22x22) — used where the bar shares + -- a row with other controls (Aura Designer header). Default stays labeled + -- (Text Designer) so existing callers are untouched. + local function CreateAction(labelText, iconName, width, onClick) + if opts.iconButtons then + local b = CreateFrame("Button", nil, bar, "BackdropTemplate") + GUI:StyleButton(b, { + width = 22, height = 22, + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, + size = 14, color = C_TEXT, + }, + }) + b:SetScript("OnClick", function(self) + onClick(self) + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + b:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = labelText}) + end) + b:HookScript("OnLeave", function() GUI:HideTooltip() end) + return b + end + return GUI:CreateButton(bar, labelText, width, 22, onClick) + end + + local newBtn = CreateAction(L["New"], "add", 48, function() + PromptPresetName(L["Name the new template:"], EditingLayoutName() or "", L["Create"], function(text) + local n = DF:CreateDesignerPreset(kind, text) + if n then + DF:SetModeDesignerPreset(kind, getMode(), n) + bar:Refresh(); onChange() + end + end) + end) + newBtn:SetPoint("LEFT", ddBtn, "RIGHT", 6, 0) + + local dupBtn = CreateAction(L["Duplicate"], "content_copy", 72, function() + local cur = CurrentName() + -- Duplicate defaults to " copy" (New uses the layout name, but a + -- duplicate is of a specific preset, so name it after the source). + PromptPresetName(L["Name the duplicated template:"], cur .. " copy", L["Duplicate"], function(text) + local n = DF:DuplicateDesignerPreset(kind, cur, text) + if n then + DF:SetModeDesignerPreset(kind, getMode(), n) + bar:Refresh(); onChange() + end + end) + end) + dupBtn:SetPoint("LEFT", newBtn, "RIGHT", 4, 0) + + local renameBtn = CreateAction(L["Rename"], "edit", 62, function() + local cur = CurrentName() + if cur == DF.DEFAULT_PRESET then return end + PromptPresetName(L["Rename template:"], cur, L["Rename"], function(text) + DF:RenameDesignerPreset(kind, cur, text) + bar:Refresh(); onChange() + end) + end) + renameBtn:SetPoint("LEFT", dupBtn, "RIGHT", 4, 0) + + local delBtn = CreateAction(L["Delete"], "delete", 56, function() + local cur = CurrentName() + if cur == DF.DEFAULT_PRESET then return end + ConfirmDeletePreset(kind, cur, function() bar:Refresh(); onChange() end) + end) + delBtn:SetPoint("LEFT", renameBtn, "RIGHT", 4, 0) + + local function SetActionEnabled(btn, on) + if on then + btn:Enable() + if btn.Text then btn.Text:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end + if btn.Icon then btn.Icon:SetVertexColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) end + else + btn:Disable() + -- greyed: Default can't be renamed/deleted + if btn.Text then btn.Text:SetTextColor(0.4, 0.4, 0.4) end + if btn.Icon then btn.Icon:SetVertexColor(0.4, 0.4, 0.4) end + end + end + + function bar:Refresh() + ddBtn.text:SetText(CurrentLabel()) + -- Make room for the share glyph only while it's up, so an unshared + -- preset keeps the full label width. + local isShared = #SharedWith() > 0 + shareIcon:SetShown(isShared) + ddBtn.text:ClearAllPoints() + ddBtn.text:SetPoint("LEFT", 6, 0) + ddBtn.text:SetPoint("RIGHT", isShared and -30 or -16, 0) + -- Rename/Delete act on the resolved preset; disable for the non-editable + -- Default and while a layout is inheriting (you're following the global, + -- not sitting on a layout-specific preset). + local inheriting = IsEditingLayout() and DF.IsLayoutDesignerInheriting + and DF:IsLayoutDesignerInheriting(kind) + local canModify = (CurrentName() ~= DF.DEFAULT_PRESET) and not inheriting + SetActionEnabled(renameBtn, canModify) + SetActionEnabled(delBtn, canModify) + end + + bar:Refresh() + -- The sharing glyph reflects OTHER refs (the other mode, a pinned set, an + -- auto layout), which can change without changing THIS mode's — and both + -- designer pages early-return their rebuild when their own preset is + -- unchanged, so the glyph would sit stale. Register the live bar so the + -- shared page refresh can reach it. One slot per kind: a rebuilt page + -- overwrites its own entry, and a torn-down bar is hidden, so nothing + -- accumulates. + GUI._designerPresetBars = GUI._designerPresetBars or {} + GUI._designerPresetBars[kind] = bar + return bar +end + +function GUI:RefreshDesignerPresetBars() + for _, bar in pairs(GUI._designerPresetBars or {}) do + if bar.Refresh and bar:IsShown() then bar:Refresh() end + end +end + +-- Creates a button with an icon and text +-- iconName is the name of the icon file (without path/extension) +-- iconSize is optional (defaults to 16) +-- align: "center" (default) or "left". Pass "left" for wide / full-width +-- list-style buttons where centred content floats (see GUI:StyleButton). +function GUI:CreateIconButton(parent, iconName, text, width, height, func, iconSize, align) + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleButton(btn, { + width = width or 120, height = height or 22, + text = text, + align = align, + icon = { + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" .. iconName, + size = iconSize or 18, + color = C_TEXT, + }, + }) + + btn:SetScript("OnClick", function(self) + if func then func(self) end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + return btn +end + +-- Creates a \"See Also:\" section with clickable links to related pages +-- links = { {pageId = \"display_tooltips\", label = \"Tooltips\"}, ... } +function GUI:CreateSeeAlso(parent, links) + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + container:SetHeight(32) + -- The page's own footer: pinned to the bottom of the viewport on a SHORT + -- page instead of floating wherever the content happened to end. See the + -- footer block in the page layout. + container.isPageFooter = true + CreateElementBackdrop(container, { + bgColor = { 0.1, 0.1, 0.1, 0.5 }, + borderColor = { 0.3, 0.3, 0.3, 0.8 }, + }) + + local label = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + label:SetPoint("TOPLEFT", 8, -10) + label:SetText(L["See Also:"]) + label:SetTextColor(0.7, 0.7, 0.7) + + local linkButtons = {} + local separators = {} + + for i, linkData in ipairs(links) do + local link = CreateFrame("Button", nil, container) + link:SetHeight(16) + + local linkText = link:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + linkText:SetPoint("TOPLEFT", 0, -1) + linkText:SetText(linkData.label) + local c = GetThemeColor() + linkText:SetTextColor(c.r, c.g, c.b) + link.text = linkText + link.textWidth = linkText:GetStringWidth() + 4 + link:SetWidth(link.textWidth) + + link:SetScript("OnEnter", function(self) + local h = GUI:LinkHoverColor(c) + linkText:SetTextColor(h.r, h.g, h.b) + end) + link:SetScript("OnLeave", function(self) + linkText:SetTextColor(c.r, c.g, c.b) + end) + link:SetScript("OnClick", function() + if GUI.SelectTab then + GUI.SelectTab(linkData.pageId) + end + end) + + table.insert(linkButtons, link) + + -- Create separator (hidden by default, shown as needed) + if i < #links then + local sep = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + sep:SetText("•") + sep:SetTextColor(0.5, 0.5, 0.5) + table.insert(separators, sep) + end + end + + -- Layout function that handles wrapping + local function LayoutLinks() + local containerWidth = container:GetWidth() + if containerWidth < 50 then return end -- Not sized yet + + local labelWidth = label:GetStringWidth() + 16 + local firstLinkX = labelWidth -- Where first link starts + local xOffset = labelWidth + local yOffset = -9 + local lineHeight = 18 + local maxX = containerWidth - 10 + local rowCount = 1 + + -- First pass: determine which links are on which row + local linkRows = {} + local tempX = labelWidth + local currentRow = 1 + + for i, link in ipairs(linkButtons) do + local linkWidth = link.textWidth + local sepWidth = (i < #linkButtons) and 14 or 0 + + -- Check if we need to wrap + if tempX + linkWidth > maxX and tempX > labelWidth then + currentRow = currentRow + 1 + tempX = firstLinkX + end + + linkRows[i] = currentRow + tempX = tempX + linkWidth + sepWidth + end + + rowCount = currentRow + + -- Second pass: position elements + xOffset = labelWidth + local lastRowForLink = 1 + + for i, link in ipairs(linkButtons) do + local linkWidth = link.textWidth + + -- Check if we need to wrap to new line + if linkRows[i] > lastRowForLink then + xOffset = firstLinkX + yOffset = yOffset - lineHeight + lastRowForLink = linkRows[i] + end + + link:ClearAllPoints() + link:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset) + + xOffset = xOffset + linkWidth + 2 + + -- Position separator only if next link is on same row + if separators[i] then + if linkRows[i + 1] == linkRows[i] then + separators[i]:ClearAllPoints() + separators[i]:SetPoint("TOPLEFT", container, "TOPLEFT", xOffset, yOffset - 1) + separators[i]:Show() + xOffset = xOffset + 12 + else + separators[i]:Hide() + end + end + end + + -- Adjust container height based on rows. + -- + -- Snapped HERE, where the number is computed, because this widget + -- MEASURES ITSELF: LayoutLinks runs from OnSizeChanged and from a + -- C_Timer.After(0), i.e. a frame AFTER the page layout has run. + -- Correcting the height after the fact cannot win -- whatever sets a + -- grid-aligned height, this function overwrites it on the next frame, + -- and the bar's bottom edge ends up split across two device rows. + -- Measured: 28 units at 1.40625 px/unit is 39.375px, 0.375 off. + local newHeight = SnapLen(container, 10 + (rowCount * lineHeight)) + container:SetHeight(newHeight) + container.layoutHeight = newHeight + 5 + end + + container:SetScript("OnSizeChanged", LayoutLinks) + + -- Initial layout after a frame (to let width be set) + C_Timer.After(0, LayoutLinks) + + return container +end + +-- A bare checkbox sized for a LIST ROW — no label, no db binding, no settings-row +-- geometry. GUI:CreateCheckbox is a whole 30px settings row with its own label and +-- hit rect, which is the wrong shape entirely inside a 22px pooled list row that +-- already owns its own text, count and selection accent. +-- +-- The caller drives it: :SetChecked(bool) to paint, opts.onClick to react. It does +-- NOT read or write the db itself, because a list row's meaning changes per bind +-- (a pooled row is a different filter every refresh) and a captured dbKey would go +-- stale the moment the pool rebinds. +-- +-- ⚠ Clicks deliberately do NOT propagate. The override marker above lets them fall +-- through because it is a passive marker; this is a control, and on a clickable row +-- the two gestures must stay separate — tick the box to switch the filter on, click +-- anywhere else to select it. Nothing here calls SetPropagateMouseClicks, which is +-- PROTECTED on 12.1 anyway (see the note in CreateOverrideMarker). +-- +-- ⚠ The BOX ITSELF is GUI:StyleCheckButton, the addon's one checkbox look — do not +-- hand-roll it again. This was hand-rolled once and drifted four ways: it drew the +-- Media\Icons\check GLYPH where every other checkbox in the addon draws a filled +-- WHITE8x8 square (a different SYMBOL, not a different style), it skipped PixelUtil +-- so it alone was unsnapped, it had no hover wash, and it recoloured its BORDER when +-- checked, which nothing else does. CreateDebugCategoryRow is the precedent for this +-- exact case — a list row with a checkbox — at the same size. +-- +-- manualCheck because this is a plain Button, not a CheckButton: SetChecked below +-- drives the mark. A real CheckButton would draw its checked mark through the native +-- checked state, which has no disabled-checked texture here — so a greyed-but-ticked +-- row (every filter row while All Buffs is on) would lose its tick entirely. +-- +-- opts: { size = 16, checkSize = 9, onClick = function(checked) end, +-- tooltip = title, tooltipDesc = line } +function GUI:CreateRowToggle(parent, opts) + opts = opts or {} + local btn = CreateFrame("Button", nil, parent, "BackdropTemplate") + GUI:StyleCheckButton(btn, { + size = opts.size or 16, + checkSize = opts.checkSize or 9, + manualCheck = true, + }) + + btn.checked = false + function btn:SetChecked(on) + self.checked = on and true or false + -- Re-tint on every paint. StyleCheckButton registers its theme listener on + -- this button's PARENT, which for a pooled list row is a frame inside a + -- scroll child that the page's theme walk never visits (same trap as + -- StyleButton's wash). The list rebinds every row on refresh, and a refresh + -- is what a mode switch produces, so painting the accent here is what + -- actually carries party purple -> raid orange. + self.ApplyThemeColor(GetThemeColor()) + self.Check:SetShown(self.checked) + end + + btn:SetScript("OnClick", function(s) + if s.onClick then s.onClick(not s.checked) end + end) + btn:SetScript("OnEnter", function(s) + if s.tooltipText then + GUI:ShowTooltip(s, { title = s.tooltipText, lines = s.tooltipDesc and { s.tooltipDesc } or nil }) + end + end) + btn:SetScript("OnLeave", function() GUI:HideTooltip() end) + + btn.onClick = opts.onClick + btn.tooltipText = opts.tooltip + btn.tooltipDesc = opts.tooltipDesc + btn:SetChecked(false) + return btn +end + +function GUI:CreateCheckbox(parent, label, dbTable, dbKey, callback, customGet, customSet, overrideKey) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(220, 24) + container.preferredHeight = GUI.RowHeight.checkbox -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "checkbox" -- /df debug gapcheck groups the spacing report by this + container.fixedRowHeight = true + + local cb = CreateFrame("CheckButton", nil, container, "BackdropTemplate") + cb:SetPoint("LEFT", 0, 0) + -- Box + themed check come from the shared styler (single source of truth). + GUI:StyleCheckButton(cb, { themeRoot = parent }) + + -- Label + local txt = container:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + txt:SetText(label) + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + container.label = txt -- exposed so callers can re-font / anchor a subtitle + + -- Determine the key to use for override indicators + local effectiveOverrideKey = overrideKey or dbKey + + -- Add override indicators if we have a key (either dbKey or overrideKey) + if effectiveOverrideKey and type(effectiveOverrideKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(effectiveOverrideKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(effectiveOverrideKey) + cb:SetChecked(globalVal) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + elseif customSet then + customSet(globalVal) + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(container, txt, effectiveOverrideKey, onReset, nil, nil, dbTable) + end + + local function UpdateState() + local val = false + if customGet then val = customGet() elseif dbTable and dbKey then val = dbTable[dbKey] end + cb:SetChecked(val) + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + end + + container:SetScript("OnShow", UpdateState) + -- Re-read the source and repaint the box, for a caller that changed the value + -- behind the widget's back (a "set all" button). Same contract as + -- CreateSegmentToggle:Refresh(); before this, call sites reached for a + -- Hide()/Show() bounce to fire the OnShow above. + container.Refresh = UpdateState + cb:SetScript("OnClick", function(self) + local val = self:GetChecked() + -- Was gated on DF.debugEnabled and printed straight to CHAT, bypassing the + -- console entirely. GUI is the right category and it is already declared. + DF:Debug("GUI", "checkbox OnClick: dbKey=%s overrideKey=%s value=%s", + tostring(dbKey), tostring(overrideKey), tostring(val)) + + -- Runtime override protection: redirect to baseline, skip refresh + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(effectiveOverrideKey, val) then + if container.UpdateOverrideIndicators then container:UpdateOverrideIndicators(val) end + return + end + + if customSet then customSet(val) elseif dbTable and dbKey then dbTable[dbKey] = val end + + -- If editing a profile, also set the override (use effectiveOverrideKey) + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and effectiveOverrideKey then + DF.AutoProfilesUI:SetProfileSetting(effectiveOverrideKey, val) + end + + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(val) + end + + if callback then + DF:Debug("GUI", "checkbox OnClick: calling callback") + callback() + end + if parent.RefreshStates then + DF:Debug("GUI", "checkbox OnClick: calling RefreshStates") + parent:RefreshStates() + end + DF:Debug("GUI", "checkbox OnClick: calling DF:UpdateAll") + DF:UpdateAll() + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget (box + check fill + label) so a disabled CHECKED + -- box greys too: native SetEnabled has no DisabledCheckedTexture, so the + -- accent check would otherwise stay full-bright. + self:SetAlpha(enabled and 1 or 0.4) + cb:SetEnabled(enabled) + if enabled then + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + -- Tooltip: shared attach on the LABEL only (see GUI:AttachTooltip). The + -- earlier hit-rect arithmetic here is gone with it — the hit frame is anchored + -- to the FontString, so a label overflowing the fixed 220 container is covered + -- for free rather than by widening the container's hit rect to match. + GUI:AttachTooltip(container, label, txt) + + UpdateState() + + -- SEARCH: Register this setting + if DF.Search then + local hasCustomGetSet = (customGet ~= nil or customSet ~= nil) + if dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterCheckbox(label, dbKey, nil, false, callback) + elseif hasCustomGetSet then + container.searchEntry = DF.Search:RegisterCheckbox(label, nil, nil, true, callback) + end + end + + return container +end + +function GUI:CreateSegmentToggle(parent, segments, dbTable, dbKey, callback, opts) + opts = opts or {} + local segW = opts.segmentWidth or 26 + local h = opts.height or 18 + local pad = 1 -- track lip around the buttons + + local container = CreateFrame("Frame", nil, parent, "BackdropTemplate") + container:SetSize(segW * #segments + pad * 2, h + pad * 2) + CreateElementBackdrop(container) -- the recessed track behind every segment + + -- One read/write pair for both pathways, so the click handler and Refresh + -- can't drift apart. Explicit ifs, not `a and b or c` — a stored value of + -- false/nil is legitimate. + local function GetValue() + if opts.customGet then return opts.customGet() end + if dbTable and dbKey then return dbTable[dbKey] end + end + local function SetValue(v) + if opts.customSet then opts.customSet(v) return true end + if dbTable and dbKey then dbTable[dbKey] = v return true end + return false + end + + local buttons = {} + for i, seg in ipairs(segments) do + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + GUI:StyleButton(btn, { width = segW, height = h, text = seg.label }) + GUI:SetSettingsFont(btn.Text, 9, "") + btn:SetPoint("TOPLEFT", container, "TOPLEFT", pad + (i - 1) * segW, -pad) + btn.value = seg.value + if seg.tooltip then + btn:HookScript("OnEnter", function(self) + GUI:ShowTooltip(self, { title = seg.tooltip, lines = opts.tooltipLines }) + end) + btn:HookScript("OnLeave", function() GUI:HideTooltip() end) + end + btn:SetScript("OnClick", function(self) + if GetValue() == self.value then return end + if not SetValue(self.value) then return end + container:Refresh() + if callback then callback(self.value) end + end) + buttons[i] = btn + end + + -- Selection: the shared accent border/fill via SetActive, plus a bright/dim + -- label so the state still reads at a glance in a themed accent that is close + -- to the resting border colour. + function container:Refresh() + local cur = GetValue() + local matched = false + for _, b in ipairs(buttons) do if b.value == cur then matched = true end end + if not matched then cur = opts.fallbackValue end + for _, b in ipairs(buttons) do + local on = (b.value == cur) + b:SetActive(on) + if b.Text then + local c = on and C_TEXT or C_TEXT_DIM + b.Text:SetTextColor(c.r, c.g, c.b) + end + end + end + container.refreshContent = function(self) self:Refresh() end + + container.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + for _, b in ipairs(buttons) do b:EnableMouse(enabled) end + end + + container.UpdateTheme = function() container:Refresh() end + if not parent.ThemeListeners then parent.ThemeListeners = {} end + table.insert(parent.ThemeListeners, container) + + container:Refresh() + return container +end + +-- opts.noisy marks a firehose category: one user action can produce dozens of +-- lines, which evicts the trace the log was opened to capture. It renders as the +-- shared caution icon with its own tooltip rather than a "(noisy)" suffix baked +-- into the description string -- the suffix was untranslatable in place, and it +-- competed with the description for the same line of text. +function GUI:CreateDebugCategoryRow(parent, categoryKey, description, width, noisy) + local row = CreateFrame("Frame", nil, parent) + row:SetSize(width or 520, 28) + row:EnableMouse(true) + + -- Hover background + row.hoverBg = row:CreateTexture(nil, "BACKGROUND") + row.hoverBg:SetAllPoints() + row.hoverBg:SetColorTexture(1, 1, 1, 0.05) + row.hoverBg:Hide() + + -- Checkbox + local cb = CreateFrame("CheckButton", nil, row, "BackdropTemplate") + cb:SetPoint("LEFT", 4, 0) + GUI:StyleCheckButton(cb, { size = 16, checkSize = 9, themeRoot = parent }) + cb:EnableMouse(false) -- forward clicks to the row + + -- Category name (bold, full opacity) + local nameTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlight") + nameTxt:SetPoint("LEFT", cb, "RIGHT", 8, 0) + nameTxt:SetWidth(86) + nameTxt:SetJustifyH("LEFT") + nameTxt:SetText(categoryKey) + nameTxt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Caution icon for a firehose category. Its own hit area, so it can carry a + -- different tooltip from the row without stealing the row's click: the frame + -- only enables mouse, it has no OnMouseUp, so a click still falls through to + -- the row underneath and toggles the category like anywhere else on it. + local noisyIcon + if noisy then + noisyIcon = CreateFrame("Frame", nil, row) + noisyIcon:SetSize(14, 14) + noisyIcon:SetPoint("RIGHT", row, "RIGHT", -8, 0) + noisyIcon:EnableMouse(true) + noisyIcon:SetFrameLevel(row:GetFrameLevel() + 2) + local tex = noisyIcon:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + tex:SetTexture("Interface\\AddOns\\DandersFrames\\Media\\Icons\\warning") + -- The caution tone's ICON colour, read straight from the shared tone table + -- so this stays in step with every banner and note that uses it. + -- ☠ Read at CALL time, not through a file-scope alias. INFO_BANNER_TONES + -- is defined in GUI/Sections.lua, which lives in the load-on-demand + -- companion, while this file is resident -- an alias captured at load + -- would be nil forever and never see the companion's later publish. + -- This function is only ever called from a settings page, so the + -- companion is loaded by the time it runs. + local ic = GUI._priv.INFO_BANNER_TONES.caution.iconColor + tex:SetVertexColor(ic[1], ic[2], ic[3]) + noisyIcon:SetScript("OnEnter", function(self) + -- Keep the row's wash up: the pointer is still over the row, and + -- letting it drop would read as the row losing focus. + row.hoverBg:Show() + GUI:ShowTooltip(self, { + title = L["Noisy category"], + lines = { L["This category can fill the log very quickly, burying the entries you are looking for."], + L["Turn it on only while reproducing the bug it relates to."] }, + tone = "caution", + }) + end) + noisyIcon:SetScript("OnLeave", function() + row.hoverBg:Hide() + GUI:HideTooltip() + end) + row.noisyIcon = noisyIcon + end + + -- Description (dim, fills remaining space, wraps if too long) + if description and description ~= "" then + local descTxt = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + descTxt:SetPoint("LEFT", nameTxt, "RIGHT", 12, 0) + -- Stop short of the icon rather than running under it. + if noisyIcon then + descTxt:SetPoint("RIGHT", noisyIcon, "LEFT", -6, 0) + else + descTxt:SetPoint("RIGHT", row, "RIGHT", -8, 0) + end + descTxt:SetJustifyH("LEFT") + descTxt:SetText(description) + descTxt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + row.descTxt = descTxt + end + + -- State helpers — read/write filters[categoryKey] + -- Absent or true = logged, explicit false = not logged + row.RefreshState = function() + local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters + local checked = (not filters) or filters[categoryKey] ~= false + cb:SetChecked(checked) + end + + local function ToggleState() + local filters = DandersFramesDB_v2 and DandersFramesDB_v2.debug and DandersFramesDB_v2.debug.filters + if not filters then return end + -- Toggle: false -> true, anything else -> false + if filters[categoryKey] == false then + filters[categoryKey] = true + else + filters[categoryKey] = false + end + row.RefreshState() + if DF.DebugConsole then DF.DebugConsole:RefreshDisplay() end + end + + row:SetScript("OnMouseUp", function(_, button) + if button == "LeftButton" then ToggleState() end + end) + + row:SetScript("OnEnter", function(self) + self.hoverBg:Show() + if description and description ~= "" then + GUI:ShowTooltip(self, { title = categoryKey, lines = { description } }) + end + end) + row:SetScript("OnLeave", function(self) + self.hoverBg:Hide() + GUI:HideTooltip() + end) + + row:SetScript("OnShow", row.RefreshState) + row.RefreshState() + + return row +end + +function GUI:CreateInput(parent, label, width) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 180, 44) + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local editbox = CreateFrame("EditBox", nil, frame) + -- Two-corner anchored, so the offset and the height are the ONLY levers -- + -- Nothing corrects a frame's position at runtime, and controls are not + -- position-corrected at all any more. Snap both and all four edges land. + local ebY = SnapLen(editbox, -15) or -15 + editbox:SetPoint("TOPLEFT", 0, ebY) + editbox:SetPoint("TOPRIGHT", 0, ebY) + editbox:SetHeight(SnapLen(editbox, 24) or 24) + GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets + editbox:SetAutoFocus(false) + editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editbox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) + + -- Grey-when-disabled parity with CreateEditBox (cheap insurance if ever placed + -- in a gated group): dim the whole widget + block editing. + frame.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + editbox:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + frame.EditBox = editbox + -- Tooltip: shared attach on the LABEL only. This factory carried no tooltip + -- support at all, so a caller that set .tooltip on it got silence — + -- Options.lua's custom range-spell input did exactly that, and its + -- explanation never appeared. Keeping it off the edit box also means it can't + -- cover what you are typing. + GUI:AttachTooltip(frame, label, lbl) + return frame +end + +-- CreateEditBox: Text input with db binding (for settings like custom text) +function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) + local frame = CreateFrame("Frame", nil, parent) + frame:SetSize(width or 180, 44) + frame.preferredHeight = GUI.RowHeight.editbox -- factory-owned slot height (see GUI.RowHeight) + frame.rowKind = "editbox" + frame.fixedRowHeight = true + + local lbl = frame:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetPoint("TOPLEFT", 0, 0) + lbl:SetText(label) + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Add override indicators if dbKey is provided + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if dbTable and dbKey then + dbTable[dbKey] = globalVal + end + if frame.EditBox then + frame.EditBox:SetText(globalVal or "") + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(globalVal) + end + DF:UpdateAll() + if callback then callback() end + end + end + AddOverrideIndicators(frame, lbl, dbKey, onReset, 6, nil, dbTable) + end + + local editbox = CreateFrame("EditBox", nil, frame) + -- Two-corner anchored, so the offset and the height are the ONLY levers -- + -- Nothing corrects a frame's position at runtime, and controls are not + -- position-corrected at all any more. Snap both and all four edges land. + local ebY = SnapLen(editbox, -15) or -15 + editbox:SetPoint("TOPLEFT", 0, ebY) + editbox:SetPoint("TOPRIGHT", 0, ebY) + editbox:SetHeight(SnapLen(editbox, 24) or 24) + GUI:StyleEditBox(editbox) -- shared input chrome: fill, border, font, insets + editbox:SetAutoFocus(false) + + -- Set initial value from db + if dbTable and dbKey then + editbox:SetText(dbTable[dbKey] or "") + end + + -- Save on enter or focus lost + local function SaveValue() + if dbTable and dbKey then + local val = editbox:GetText() + -- Runtime override protection + if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then + if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(val) end + return + end + dbTable[dbKey] = val + -- Track override when editing a profile + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() then + DF.AutoProfilesUI:SetProfileSetting(dbKey, val) + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(val) + end + if callback then callback() end + end + end + + editbox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + editbox:SetScript("OnEnterPressed", function(self) + SaveValue() + self:ClearFocus() + end) + editbox:SetScript("OnEditFocusLost", SaveValue) + + -- Optional placeholder: greyed example text shown while the box is empty + -- and unfocused. Purely cosmetic — never written to the db. + if placeholder and placeholder ~= "" then + local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") + ph:SetPoint("LEFT", 5, 0) + ph:SetPoint("RIGHT", -5, 0) + ph:SetJustifyH("LEFT") + ph:SetText(placeholder) + ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) + local function UpdatePlaceholder() + ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") + end + -- Exposed so a caller that puts something INSIDE the box (see + -- GUI:AddEditBoxIcon) can move the placeholder clear of it. + editbox.Placeholder = ph + editbox.UpdatePlaceholder = UpdatePlaceholder + editbox:HookScript("OnTextChanged", UpdatePlaceholder) + editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) + editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) + UpdatePlaceholder() + end + + -- Refresh override indicators on show + frame:SetScript("OnShow", function() + if dbTable and dbKey then + editbox:SetText(dbTable[dbKey] or "") + end + if frame.UpdateOverrideIndicators then + frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) + end + if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end + end) + + -- Grey-when-disabled: the grey loop (RefreshChildStates) calls widget:SetEnabled, + -- but this frame had none, so a disabled group left the input full-bright AND + -- editable. Dim the whole widget + block editing, matching the other helpers. + frame.SetEnabled = function(self, enabled) + self:SetAlpha(enabled and 1 or 0.4) + editbox:SetEnabled(enabled) + if enabled then + lbl:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + else + lbl:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + end + end + + frame.EditBox = editbox + return frame +end + +function GUI:AddEditBoxIcon(editbox, texture, size) + if not editbox or not texture then return end + size = size or 14 + local icon = editbox:CreateTexture(nil, "OVERLAY") + icon:SetSize(size, size) + icon:SetPoint("LEFT", 6, 0) + icon:SetTexture(texture) + icon:SetVertexColor(0.72, 0.72, 0.72) + editbox.Icon = icon + + local left = 6 + size + 5 + local _, right, top, bottom = editbox:GetTextInsets() + editbox:SetTextInsets(left, right, top, bottom) + if editbox.Placeholder then + editbox.Placeholder:SetPoint("LEFT", left, 0) + end + return icon +end + +-- Stamp the shared Frame Level explanation onto a slider. One helper rather than the same +-- two strings at 21 call sites, and it keeps the wording in ONE place -- the old per-page +-- label went stale the moment the scale changed (it still read "0=Auto" afterwards). +-- Takes the CONTAINER that CreateSlider returns, which is what every call site has. +function GUI:SetFrameLevelTooltip(container) + if not container then return end + container.tooltipText = L["Frame Level"] + container.tooltipSubText = L["Higher numbers draw on top of lower ones. Every Frame Level in DandersFrames uses the same scale, counted up from the unit frame, so you can compare them directly."] + return container -- chainable, so it wraps a CreateSlider call in place +end + +-- Dual-handle range slider: two draggable handles select a [lo, hi] sub-range of +-- [minRange, maxRange]. Self-contained — the caller anchors the returned track +-- frame and reads values via the onChange callback. (:GetValues() also exists and +-- completes the SetValues pair, but no current consumer polls it.) Drag is +-- tracked on the track's own OnUpdate (no dependence on parent scripts), and a +-- mouse-button check releases the drag even if the cursor leaves the handle. +-- opts: +-- width(336), accent({r,g,b}=theme), minRange, maxRange, lo, hi, +-- scaleLabels({...} optional tick labels), scaleMin/scaleMax (label scale, +-- default minRange/maxRange — lets ticks stay on a fixed scale while the +-- handle range changes), display(FontString updated each change), +-- formatRange(fn(lo,hi)->str), formatOne(fn(v)->str), +-- onChange(fn(lo,hi) — fired on user-driven changes only, not SetValues). +-- Methods on the returned frame: :SetRange(min,max), :SetValues(lo,hi), +-- :GetValues()->lo,hi. +function GUI:CreateRangeSlider(parent, opts) + opts = opts or {} + local width = opts.width or 336 + local accent = opts.accent or GetThemeColor() + + local track = CreateFrame("Frame", nil, parent, "BackdropTemplate") + track:SetSize(width, 12) + CreateElementBackdrop(track, { + bgColor = { 0.03, 0.03, 0.03, 1 }, + borderColor = { 0.2, 0.2, 0.2, 1 }, + }) + + track.minRange = opts.minRange or 1 + track.maxRange = opts.maxRange or 40 + track.lo = opts.lo or track.minRange + track.hi = opts.hi or track.maxRange + + local rangeFill = track:CreateTexture(nil, "ARTWORK") + rangeFill:SetTexture("Interface\\Buttons\\WHITE8x8") + rangeFill:SetVertexColor(accent.r, accent.g, accent.b, 0.5) + rangeFill:SetHeight(10) + rangeFill:SetPoint("TOP", 0, -1) + + local function MakeHandle() + local h = CreateFrame("Button", nil, track) + h:SetSize(8, 16) + h:EnableMouse(true) + local tex = h:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + tex:SetTexture("Interface\\Buttons\\WHITE8x8") + tex:SetVertexColor(accent.r, accent.g, accent.b, 1) + return h + end + local minHandle, maxHandle = MakeHandle(), MakeHandle() + track.minHandle, track.maxHandle = minHandle, maxHandle + + local function ValueToPos(value) + local pct = (value - track.minRange) / (track.maxRange - track.minRange) + return pct * (width - 4) + 2 + end + local function PosToValue(pos) + local pct = (pos - 2) / (width - 4) + return math.floor(pct * (track.maxRange - track.minRange) + track.minRange + 0.5) + end + + local function Redraw() + local minPos, maxPos = ValueToPos(track.lo), ValueToPos(track.hi) + minHandle:ClearAllPoints() + minHandle:SetPoint("CENTER", track, "LEFT", minPos, 0) + maxHandle:ClearAllPoints() + maxHandle:SetPoint("CENTER", track, "LEFT", maxPos, 0) + rangeFill:ClearAllPoints() + rangeFill:SetPoint("LEFT", track, "LEFT", minPos, 0) + rangeFill:SetWidth(math.max(maxPos - minPos, 2)) + if opts.display then + if track.lo == track.hi then + opts.display:SetText(opts.formatOne and opts.formatOne(track.lo) or tostring(track.lo)) + else + opts.display:SetText(opts.formatRange and opts.formatRange(track.lo, track.hi) + or (track.lo .. " - " .. track.hi)) + end + end + end + + local dragging = nil + local function ApplyCursor() + local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() + local trackLeft = track:GetLeft() + if not trackLeft then return end + local pos = math.max(2, math.min(x - trackLeft, width - 2)) + local value = math.max(track.minRange, math.min(PosToValue(pos), track.maxRange)) + if dragging == "min" then + if value <= track.hi then track.lo = value end + elseif dragging == "max" then + if value >= track.lo then track.hi = value end + end + Redraw() + if opts.onChange then opts.onChange(track.lo, track.hi) end + end + + minHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "min" end end) + maxHandle:SetScript("OnMouseDown", function(_, b) if b == "LeftButton" then dragging = "max" end end) + track:SetScript("OnUpdate", function() + if not dragging then return end + if not IsMouseButtonDown("LeftButton") then dragging = nil; return end + ApplyCursor() + end) + + track:EnableMouse(true) + track:SetScript("OnMouseDown", function(_, b) + if b ~= "LeftButton" then return end + local x = select(1, GetCursorPosition()) / UIParent:GetEffectiveScale() + local trackLeft = track:GetLeft() + if not trackLeft then return end + local value = PosToValue(x - trackLeft) + if math.abs(value - track.lo) <= math.abs(value - track.hi) then + if value <= track.hi then track.lo = math.max(track.minRange, value) end + else + if value >= track.lo then track.hi = math.min(track.maxRange, value) end + end + Redraw() + if opts.onChange then opts.onChange(track.lo, track.hi) end + end) + + if opts.scaleLabels then + local sMin = opts.scaleMin or track.minRange + local sMax = opts.scaleMax or track.maxRange + for _, num in ipairs(opts.scaleLabels) do + local pct = (num - sMin) / (sMax - sMin) + local xPos = pct * (width - 4) + 2 + local lbl = track:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + lbl:SetText(num) + lbl:SetTextColor(0.35, 0.35, 0.35) + lbl:SetPoint("TOP", track, "BOTTOM", xPos - width / 2, -2) + end + end + + function track:SetRange(minR, maxR) + self.minRange, self.maxRange = minR, maxR + self.lo = math.max(minR, math.min(self.lo, maxR)) + self.hi = math.max(minR, math.min(self.hi, maxR)) + Redraw() + end + function track:SetValues(lo, hi) + self.lo = math.max(self.minRange, math.min(lo, self.maxRange)) + self.hi = math.max(self.minRange, math.min(hi, self.maxRange)) + Redraw() + end + function track:GetValues() return self.lo, self.hi end + + Redraw() + return track +end + +function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback, lightweightCallback, useLightweight) + local container = CreateFrame("Frame", nil, parent) + container:SetSize(260, 28) + container.preferredHeight = GUI.RowHeight.colorpicker -- factory-owned slot height (see GUI.RowHeight) + container.rowKind = "colorpicker" + container.fixedRowHeight = true + + -- Button - use relative anchoring so it resizes with container + local btn = CreateFrame("Button", nil, container, "BackdropTemplate") + btn:SetPoint("TOPLEFT", 0, 0) + btn:SetPoint("TOPRIGHT", 0, 0) + btn:SetHeight(SnapLen(btn, 24) or 24) + CreateElementBackdrop(btn) + + -- Label + local txt = btn:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + txt:SetPoint("LEFT", 8, 0) + txt:SetText(label) + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Color swatch + local swatch = btn:CreateTexture(nil, "OVERLAY") + swatch:SetSize(40, 16) + swatch:SetPoint("RIGHT", -6, 0) + + -- Add override indicators if dbKey is provided (for auto profiles) + if dbKey and type(dbKey) == "string" then + local function onReset() + if DF.AutoProfilesUI then + DF.AutoProfilesUI:ResetProfileSetting(dbKey) + -- Refresh to global value + local globalVal = DF.AutoProfilesUI:GetGlobalValue(dbKey) + if globalVal then + dbTable[dbKey].r = globalVal.r + dbTable[dbKey].g = globalVal.g + dbTable[dbKey].b = globalVal.b + dbTable[dbKey].a = globalVal.a or 1 + end + if container.UpdateSwatch then + container:UpdateSwatch() + end + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(dbTable[dbKey]) + end + DF:UpdateAll() + end + end + AddOverrideIndicators(container, txt, dbKey, onReset, nil, nil, dbTable) + end + + local function UpdateSwatch() + if dbTable and dbKey and dbTable[dbKey] then + local c = dbTable[dbKey] + swatch:SetColorTexture(c.r, c.g, c.b, c.a or 1) + -- Update override indicators + if container.UpdateOverrideIndicators then + container:UpdateOverrideIndicators(c) + end + end + end + container.UpdateSwatch = UpdateSwatch -- Expose for reset + + btn:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + btn:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + -- Tooltip: shared attach on the LABEL only. This factory carried none, across + -- 87 colour pickers. The btn keeps its own hover scripts untouched — the hit + -- frame is over the text, not the swatch. + GUI:AttachTooltip(container, label, txt) + + btn:SetScript("OnClick", function() + if not dbTable then return end + local c = dbTable[dbKey] + if not c then + c = {r = 1, g = 1, b = 1, a = 1} + dbTable[dbKey] = c + end + + -- Store original values for cancel + local originalColor = {r = c.r, g = c.g, b = c.b, a = c.a or 1} + + -- Blizzard's SetupColorPickerAndShow fires swatchFunc once DURING + -- setup (its SetColorRGB triggers OnColorSelect — the source comments + -- it). That spurious fire re-writes the unchanged colour and runs the + -- change callbacks on mere open: it commits per-element override flags + -- (Text Designer) and triggers a pointless full refresh. Suppress + -- callbacks until setup has returned. + local settingUp = true + + local info = { + swatchFunc = function() + if settingUp then return end + local r, g, b = ColorPickerFrame:GetColorRGB() + local a = 1 + if hasAlpha and ColorPickerFrame.GetColorAlpha then + a = ColorPickerFrame:GetColorAlpha() or 1 + end + dbTable[dbKey].r = r + dbTable[dbKey].g = g + dbTable[dbKey].b = b + dbTable[dbKey].a = a + + -- If editing a profile, also set the override + if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then + DF.AutoProfilesUI:SetProfileSetting(dbKey, {r = r, g = g, b = b, a = a}) + end + + UpdateSwatch() + -- Use lightweight callback during dragging if available + if useLightweight and lightweightCallback then + lightweightCallback() + else + DF:ThrottledUpdateAll() + if callback then callback() end + end + end, + hasOpacity = hasAlpha, + opacityFunc = hasAlpha and function() + if settingUp then return end + if ColorPickerFrame.GetColorAlpha then + local a = ColorPickerFrame:GetColorAlpha() + if a then + dbTable[dbKey].a = a + UpdateSwatch() + -- Use lightweight callback during dragging if available + if useLightweight and lightweightCallback then + lightweightCallback() + else + DF:ThrottledUpdateAll() + if callback then callback() end + end + end + end + end or nil, + cancelFunc = function(restore) + -- Restore original color on cancel + dbTable[dbKey].r = originalColor.r + dbTable[dbKey].g = originalColor.g + dbTable[dbKey].b = originalColor.b + dbTable[dbKey].a = originalColor.a + UpdateSwatch() + DF:UpdateAll() + if callback then callback() end + end, + r = c.r or 1, + g = c.g or 1, + b = c.b or 1, + opacity = hasAlpha and (c.a or 1) or nil, + } + + -- Hook the OK button to run full update when confirmed + if useLightweight and lightweightCallback then + -- We need to run full update when picker is closed via OK + -- Use a frame to detect when color picker closes + if not container.colorPickerWatcher then + container.colorPickerWatcher = CreateFrame("Frame") + end + container.colorPickerWatcher:SetScript("OnUpdate", function(self) + if not ColorPickerFrame:IsShown() then + self:SetScript("OnUpdate", nil) + -- Only run if color changed (not cancelled) + local cur = dbTable[dbKey] + if cur.r ~= originalColor.r or cur.g ~= originalColor.g or + cur.b ~= originalColor.b or cur.a ~= originalColor.a then + DF:UpdateAll() + if callback then callback() end + end + end + end) + end + + -- Attach default colour so the picker can offer a Default button + -- dbTable.__dfDefaults is set by callers (e.g. Aura Designer proxies) that + -- store their defaults outside DF.PartyDefaults / DF.RaidDefaults. Read via + -- rawget so proxies' __index doesn't see this lookup as a regular setting. + local defaultVal = (dbTable and rawget(dbTable, "__dfDefaults") and dbTable.__dfDefaults[dbKey]) + or (DF.PartyDefaults and DF.PartyDefaults[dbKey]) + or (DF.RaidDefaults and DF.RaidDefaults[dbKey]) + -- Fallback: power bar colours use WoW's PowerBarColor table as their default + if not defaultVal and PowerBarColor and dbKey then + defaultVal = PowerBarColor[dbKey] + end + if defaultVal and type(defaultVal) == "table" and defaultVal.r then + info.dfDefaultColor = {r = defaultVal.r or 1, g = defaultVal.g or 1, b = defaultVal.b or 1, a = defaultVal.a or 1} + -- Populate ElvUI's "Default" button (ColorPPDefault) so it enables and + -- pastes the DF setting default when the native Blizzard picker is shown + local elvDefault = _G["ColorPPDefault"] + if elvDefault then + elvDefault.colors = info.dfDefaultColor + end + end + + -- Mark this as a DandersFrames color picker call + GUI:MarkColorPickerCall() + ColorPickerFrame:SetupColorPickerAndShow(info) + settingUp = false + end) + + container.SetEnabled = function(self, enabled) + -- Dim the whole widget so the colour swatch greys even when it's a dark + -- colour (SetDesaturated alone is invisible on near-black swatches). + self:SetAlpha(enabled and 1 or 0.4) + btn:SetEnabled(enabled) + if enabled then + txt:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + swatch:SetDesaturated(false) + else + txt:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + swatch:SetDesaturated(true) + end + end + + btn:SetScript("OnShow", UpdateSwatch) + UpdateSwatch() + + -- SEARCH: Register this setting + if DF.Search and dbKey and type(dbKey) == "string" then + container.searchEntry = DF.Search:RegisterColorPicker(label, dbKey, hasAlpha, nil, callback) + end + + return container +end + +function GUI:CloseAllMenus() + for f in pairs(self._menus) do + if f:IsShown() then f:Hide() end + end +end + +function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) + local options = { + NONE = L["None"], + OUTLINE = L["Outline"], + THICKOUTLINE = L["Thick Outline"], + MONOCHROME = L["Monochrome"], + ["MONOCHROME, OUTLINE"] = L["Monochrome Outline"], + ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], + _order = OUTLINE_FLAG_ORDER, + } + local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end + local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end + return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) +end + +function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) + local get = function() return DF:OutlineHasShadow(dbTable[dbKey]) end + local set = function(val) dbTable[dbKey] = DF:ComposeOutline(DF:OutlineFlag(dbTable[dbKey]), val) end + return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) +end + +function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) + opts = opts or {} + local parent = opts.parent + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local typeLabel = opts.typeLabel or L["Border Animation"] + local excludeTypes = opts.excludeTypes -- optional set of animation-type keys to omit from the dropdown + local hideExtra = opts.hideExtra + local onTypeChange = opts.onTypeChange or function() end + local showPerfBanner = opts.perfBanner ~= false + + local function aKey(suffix) return animPrefix .. suffix end + local animTypeKey = aKey("Type") + local function animType() return dbTable[animTypeKey] or "NONE" end + local function extraOff() return (hideExtra and hideExtra()) or false end + local function animOff() return extraOff() or animType() == "NONE" end + + -- Sets of effect types each tunable applies to (truthiness on a + -- string-keyed set). Mirrors the per-effect parameter map — keep in + -- sync with StartAnimation's branches in Frames/Border.lua. + -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash + -- thickness, Inset = dash inset. + local hasFrequency = { DF_PULSATE=1, + DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } + local hasParticles = { DF_ORBIT=1, DF_PIXEL=1 } + -- CORNERS_ONLY is hidden from the type dropdown below, but keep its param + -- entries so an indicator that still carries a saved CORNERS_ONLY value shows + -- the right controls. + local hasThickness = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_PIXEL=1 } + -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which + -- modulates the border's own edges and has no separate animRect). + local hasPositioning = { CORNERS_ONLY=1, DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } + -- Scale slider = sparkle size (DF Chase). + local hasScale = { DF_ORBIT=1 } + -- Length slider = bar length (DF Pixel's chasing bars). + local hasLength = { DF_PIXEL=1 } + local cornersOnly = { CORNERS_ONLY=1 } + local function hideUnless(set) + return function() + if animOff() then return true end + return not set[animType()] + end + end + + local w = {} + + -- All DF-owned border effects (no external glow library). The "DF " labels + -- are kept from when they sat alongside the retired LCG glows. + local animTypeOptions = { + NONE = L["None"], + DF_PULSATE = L["DF Pulsate"], + DF_ORBIT = L["DF Chase"], + DF_DASH = L["DF Dash"], + DF_FLASH = L["DF Flash"], + DF_PIXEL = L["DF Pixel"], + DF_PROC = L["DF Proc"], + BLINK = L["Blink"], + -- None first (the "off" option), then alphabetical by label. CORNERS_ONLY + -- is intentionally absent — it's kept in the engine (an existing saved + -- value still renders) but no longer offered as a pickable animation. + _order = { "NONE", "BLINK", "DF_ORBIT", + "DF_DASH", "DF_FLASH", "DF_PIXEL", "DF_PROC", "DF_PULSATE" }, + } + -- Optional caller filter: drop any excluded type from both the value map and + -- the display order (e.g. the Aura Designer border offers only the taint-safe, + -- overlay-recoverable animations — no LCG glows). + if excludeTypes then + for k in pairs(excludeTypes) do animTypeOptions[k] = nil end + local filteredOrder = {} + for _, k in ipairs(animTypeOptions._order) do + if not excludeTypes[k] then filteredOrder[#filteredOrder + 1] = k end + end + animTypeOptions._order = filteredOrder + end + w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, + animTypeOptions, + dbTable, animTypeKey, onTypeChange), 55) + -- Type dropdown respects only the extra gate (e.g. Show Border). With no + -- extra gate (Expiring override) it's always visible. + w.animationType.hideOn = hideExtra or function() return false end + + -- Perf warning: animations run an OnUpdate (or LCG internal animation) + -- per active border, which adds up in 20-30 player raids. + if showPerfBanner then + -- staticHeight ONLY where the host reflows widget WIDTHS on every layout + -- pass — i.e. the Aura Designer indicator card (its parent carries + -- dfAD_ReflowWidgets). There a self-sizing banner feeds a SetHeight -> + -- OnSizeChanged -> relayout -> SetWidth loop that drops FPS, so we predict + -- a fixed height instead. On normal settings pages the host lays out once, + -- so the banner MUST self-size to its wrapped text: a fixed height + -- overflows (text spills past the box) on narrow windows until a manual + -- drag forces a relayout. + local reflowingHost = parent and parent.dfAD_ReflowWidgets ~= nil + local perfBanner = GUI:CreateInfoBanner(parent, { + tone = "caution", + text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], + staticHeight = reflowingHost or nil, + minHeight = 56, + }) + w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) + w.animationPerfBanner.hideOn = animOff + end + + -- Animation colour applies to every effect except DF_PULSATE (which + -- modulates the border's own edge alpha — no separate colour). lightColors + -- is threaded through so AD's proxy gets live preview while dragging. + w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Color"], + dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.animationColor.hideOn = function() + return animOff() or animType() == "DF_PULSATE" + end + + -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. + -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), + -- and the OnUpdate effects fall back to a sensible default period at 0. + w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], + 0, 4, 0.05, dbTable, aKey("Frequency"), + fullUpdate, lightUpdate, true), 55) + w.animationFrequency.hideOn = hideUnless(hasFrequency) + -- ⚠ This slider genuinely means different things per effect (see the comment + -- above), which is exactly why it needs saying out loud — nobody discovers + -- "0 = hold still" by dragging. + w.animationFrequency.tooltip = L["How fast the effect runs. On DF Dash this is how quickly the dashes march around the edge, and 0 holds them still. On the others it is the pulse rate, where 0 means the effect's own default speed."] + + w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], + 1, 16, 1, dbTable, aKey("Particles"), + fullUpdate, lightUpdate, true), 55) + w.animationParticles.hideOn = hideUnless(hasParticles) + w.animationParticles.tooltip = L["How many separate lights travel around the border. More reads as busier and costs a little more to draw."] + + w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], + 1, 30, 1, dbTable, aKey("Length"), + fullUpdate, lightUpdate, true), 55) + w.animationLength.hideOn = hideUnless(hasLength) + w.animationLength.tooltip = L["How long each moving segment is. Short values read as darting sparks, long ones as a sweeping tail."] + + w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], + 1, 12, 1, dbTable, aKey("Thickness"), + fullUpdate, lightUpdate, true), 55) + w.animationThickness.hideOn = hideUnless(hasThickness) + w.animationThickness.tooltip = L["How heavy the moving effect is. Separate from Border Thickness — the animation draws on its own layer, so it can be thicker or thinner than the border underneath."] + + w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], + 0.5, 3, 0.05, dbTable, aKey("Scale"), + fullUpdate, lightUpdate, true), 55) + w.animationScale.hideOn = hideUnless(hasScale) + + w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], + -50, 50, 1, dbTable, aKey("Inset"), + fullUpdate, lightUpdate, true), 55) + w.animationInset.hideOn = hideUnless(hasPositioning) + w.animationInset.tooltip = L["Moves the effect in or out from the edge, independently of the border. Push it outward to make a glow spill past the frame."] + + w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], + -50, 50, 1, dbTable, aKey("OffsetX"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetX.hideOn = hideUnless(hasPositioning) + + w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], + -50, 50, 1, dbTable, aKey("OffsetY"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetY.hideOn = hideUnless(hasPositioning) + + -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). + w.animationHideIntro = group:AddWidget(GUI:CreateCheckbox(parent, L["Hide Intro Flash"], + dbTable, aKey("ProcStart"), fullUpdate), 30) + w.animationHideIntro.hideOn = hideUnless({ DF_FLASH = 1, DF_PROC = 1 }) + w.animationHideIntro.tooltip = L["These effects open with a one-off burst before settling into their loop. Turn this on to skip the burst and go straight to the loop."] + + w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], + 2, 40, 1, dbTable, aKey("CornerLength"), + fullUpdate, lightUpdate, true), 55) + w.animationCornerLength.hideOn = hideUnless(cornersOnly) + w.animationCornerLength.tooltip = L["How far the effect runs along each edge from the corner before stopping. Small values leave four short brackets instead of a full outline."] + + return w +end + +function GUI:CreateBorderControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local refreshStates = opts.refreshStates + local hideWhen = opts.hideWhen + local disableWhen = opts.disableWhen + + local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 + local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 + + local function key(suffix) return prefix .. suffix end + local showKey = key("ShowBorder") + -- The Show toggle only respects the parent-level hideWhen. Everything + -- else respects hideWhen OR the Show toggle being off. + -- + -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is + -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` + -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, + -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. + -- For consumers with a different dbTable — notably Aura Designer's + -- per-aura proxy — this is the only way the visibility predicates see + -- the actual border state (e.g. proxy.BorderStyle, not the unrelated + -- DF.db.party.BorderStyle which doesn't exist). + local function hideShow() return hideWhen and hideWhen(dbTable) or false end + -- Show Border OFF no longer HIDES the border controls — they stay visible and + -- GREY OUT (disableOn = borderOff, applied by the loop at the end of this + -- function) so the panel previews them. `hideOff` now means "hidden by the + -- parent/variant gate only" (whatever the consumer passes via hideWhen); the + -- name is kept so the existing `.hideOn = hideOff` references read unchanged. + local function hideOff() return hideShow() end + local function borderOff() return dbTable[showKey] == false end + + local w = {} + + -- opts.noShowToggle: suppress the built-in "Show Border" checkbox for + -- consumers that gate the whole border on an external toggle (e.g. the + -- Targeted Spells "Highlight Important Spells" master). With the checkbox + -- gone, showKey stays nil so hideOff() reduces to hideShow() — the toolkit + -- shows/hides purely on the external hideWhen. + if not opts.noShowToggle then + w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.show.hideOn = hideShow + end + + -- Slider label reads "Border Thickness" (more meaningful than "Size") but + -- the underlying db key stays `BorderSize` and spec.size in the + -- backend stays the same — purely a user-facing rename, no migration. + w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, + dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) + w.size.hideOn = hideOff + + -- Gradient is a STYLE, not a separate toggle. When the consumer opts into + -- gradient via include.gradient, we expose GRADIENT as a third dropdown + -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. + local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], + _order = { "SOLID", "TEXTURE" } } + if include.gradient then + styleOptions.GRADIENT = L["Gradient"] + -- Insert GRADIENT between SOLID and TEXTURE so the order reads + -- "simple colour → two colours → custom texture" in the dropdown. + styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } + end + w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], + styleOptions, dbTable, key("BorderStyle"), function() + -- Match the frame border: pick the first LSM border when switching + -- to Texture without one configured. + if dbTable[key("BorderStyle")] == "TEXTURE" then + local list = DF.GetBorderList and DF:GetBorderList() or nil + local t = dbTable[key("BorderTexture")] + if list and (not t or t == "" or t == "SOLID") then + dbTable[key("BorderTexture")] = next(list) + end + end + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.style.hideOn = hideOff + + -- isGradient is declared up here so the Style-dependent widget cluster + -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) + -- can sit immediately below the Style dropdown — the consequence of the + -- user's style choice reads top-to-bottom without scrolling past + -- unrelated inset / offset / blend controls first. + local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end + + w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], + DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) + w.texture.hideOn = function() + return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" + end + + -- Gradient pickers — only visible under Style = GRADIENT. Grouped here + -- (between Texture and the Colour Source dropdown) so all style-dependent + -- widgets sit directly under the Style dropdown that controls them. + -- The standalone "Border Gradient" checkbox was removed when Style + -- absorbed it; Style is now the single source of truth so it's not + -- possible to pick "Solid + Class Color" then have a Gradient checkbox + -- stomp the class colour (the previous UX bug). Legacy + -- `BorderGradientEnabled = true` profiles are migrated to + -- `BorderStyle = "GRADIENT"` on db load. + if include.gradient then + local function gradHide() return hideOff() or not isGradient() end + + w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Color"], + dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) + w.gradientStart.hideOn = gradHide + w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Color"], + dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) + w.gradientEnd.hideOn = gradHide + w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], + { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, + dbTable, key("BorderGradientDirection"), fullUpdate), 55) + w.gradientDirection.hideOn = gradHide + end + + -- Colour Source dropdown sits ABOVE the colour picker so the relationship + -- "source → resulting colour" reads top-to-bottom in the panel. The + -- options table is built dynamically: Static is always present; Class + -- and Role are added if the consumer opted in via the matching include. + -- Hidden in GRADIENT style — gradient owns its own colours, no resolver + -- chain applies (see Border:BuildSpec). + local sourceKey = key("BorderColorSource") + local hasSourceDropdown = include.classColor or include.roleColor + if hasSourceDropdown then + local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } + if include.classColor then + sourceOptions.CLASS = L["Class"] + sourceOptions._order[#sourceOptions._order + 1] = "CLASS" + end + if include.roleColor then + sourceOptions.ROLE = L["Role"] + sourceOptions._order[#sourceOptions._order + 1] = "ROLE" + end + -- Default the source from the legacy boolean keys when first opened. + if dbTable[sourceKey] == nil then + if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" + elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" + else dbTable[sourceKey] = "STATIC" end + end + w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], + sourceOptions, dbTable, sourceKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.colorSource.hideOn = function() return hideOff() or isGradient() end + w.colorSource.tooltip = L["Where the border colour comes from. Static uses the colour below; Class and Role read it from the unit, so the border tells you who you are looking at without reading the name."] + end + + -- Static colour picker — only visible when source is STATIC (or when the + -- consumer didn't enable any resolver at all, so source doesn't exist). + -- Hidden in GRADIENT style (gradient uses its own start/end pickers). + w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), + true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.color.hideOn = function() + if hideOff() or isGradient() then return true end + if hasSourceDropdown then + local src = dbTable[sourceKey] or "STATIC" + return src ~= "STATIC" + end + return false + end + + -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes + -- the SAME alpha component the colour picker exposes + -- (BorderColor.a), so the slider is just a convenient handle for + -- the picker's alpha bar — no separate alpha key to migrate or keep in + -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the + -- two gradient pickers each carry their own alpha, and a single slider + -- has no obvious meaning). + if include.alpha then + -- Ensure the underlying colour table has an alpha component so the + -- slider doesn't read nil on first open. The picker also seeds .a but + -- we don't depend on widget-creation order. + local c = dbTable[key("BorderColor")] + if type(c) ~= "table" then + c = { r = 0, g = 0, b = 0, a = 1 } + dbTable[key("BorderColor")] = c + end + if c.a == nil then c.a = 1 end + + -- Read-time nil-guard: these closures fire on the slider's OnShow at + -- arbitrary later times (tab/page re-show, mode switch), NOT just at + -- creation. The seed above only guarantees the table exists NOW — a proxy + -- dbTable can resolve BorderColor to nil later (e.g. re-showing the AD page + -- for a mode whose config doesn't surface the key), so re-read and guard + -- each call instead of assuming the table is still there. + w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, + nil, nil, fullUpdate, lightColors or lightUpdate, true, + function() + local bc = dbTable[key("BorderColor")] + return (bc and bc.a) or 1 + end, + function(v) + local bc = dbTable[key("BorderColor")] + if bc then bc.a = v end + end), 55) + w.alpha.hideOn = function() return hideOff() or isGradient() end + end + + if include.inset then + w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, + dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) + w.inset.hideOn = hideOff + -- Thickness / Inset / Offset are three similar-sounding sliders that do + -- different things; the tooltip lives here because Inset is the one + -- nobody guesses. + w.inset.tooltip = L["Pulls the border inward (positive) or pushes it outward (negative) from the edge. Thickness is how heavy the line is, Inset is how far in it sits, Offset slides the whole border sideways."] + end + + if include.offset then + w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, + dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) + w.offsetX.hideOn = hideOff + w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, + dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) + w.offsetY.hideOn = hideOff + -- No tooltip on Offset X/Y, deliberately, and the same goes for every + -- other Offset slider in the addon (~60 of them): an offset is a well + -- understood control and a tooltip restating it is noise. Inset is the + -- one that needs explaining, so the Thickness / Inset / Offset + -- distinction is spelled out THERE, once. Krathe's call, 2026-07-27 — + -- these two briefly had tooltips and Border Shadow's offsets did not, + -- which is the inconsistency that prompted it. + end + + if include.blendMode then + w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], + { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Modulate"], DISABLE = L["Disable"] }, + dbTable, key("BorderBlendMode"), fullUpdate), 55) + w.blendMode.hideOn = hideOff + w.blendMode.tooltip = L["How the border colour mixes with whatever is behind it. Blend is normal. Add brightens and is what makes a colour glow. Modulate darkens. Disable ignores opacity entirely and draws the colour flat."] + end + + if include.shadow then + local shadowOnKey = key("BorderShadowEnabled") + w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.shadowEnabled.hideOn = hideOff + -- Border Shadow OFF greys (not hides) its sub-controls — a nested boolean + -- toggle, same grey-everything rule. The end-of-function loop OR-composes + -- borderOff, so these also grey when Show Border is off. + local function shadowOff() return dbTable[shadowOnKey] == false end + + w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Color"], + dbTable, key("BorderShadowColor"), true, fullUpdate), 35) + w.shadowColor.hideOn = hideOff + w.shadowColor.disableOn = shadowOff + w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, + dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) + w.shadowSize.hideOn = hideOff + w.shadowSize.disableOn = shadowOff + w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, + dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetX.hideOn = hideOff + w.shadowOffsetX.disableOn = shadowOff + w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, + dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetY.hideOn = hideOff + w.shadowOffsetY.disableOn = shadowOff + end + + -- ===== Animation (Stage 3) ===== + -- include.animate drops the full Border Animation control set (Type + -- dropdown + per-effect tunables, each with a hideOn keyed to the effect + -- it applies to). Built from the shared GUI:CreateAnimationControls so the + -- base panel and AD's Expiring override never drift. The whole block folds + -- under Show Border via hideExtra = hideOff. Widget handles are merged back + -- onto `w` so existing references (w.animationType, …) are preserved. + if include.animate then + local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { + parent = parent, + fullUpdate = fullUpdate, + lightUpdate = lightUpdate, + lightColors = lightColors, + typeLabel = L["Border Animation"], + -- Optional caller filter, forwarded from the CreateBorderControls call + -- site (e.g. the Aura Designer border restricts to overlay-recoverable + -- animation types). nil for every other caller → full type list. + excludeTypes = opts.animExcludeTypes, + hideExtra = hideOff, + onTypeChange = function() + if refreshStates then refreshStates() end + fullUpdate() + end, + }) + for k, v in pairs(aw) do w[k] = v end + end + + -- ===== Colour resolver toggles (Stage 2) ===== + -- These flip BorderColor's source from the static picker to a per-unit / + -- per-aura / per-tick computation. BuildSpec applies them in priority + -- order (type > time > class > role > static) when the consumer passes + -- ctx to BuildSpec. The static colour picker still controls the fallback + -- (when ctx is missing or the resolver yields nil). + + -- (Colour Source dropdown + Static colour picker + Alpha slider are wired + -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the + -- relationship "source → colour" reads top-to-bottom in the panel.) + + if include.colorByTime then + w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) + w.colorByTime.hideOn = hideOff + -- The actual colour curve picker is consumer-specific (e.g. AD's + -- existing expiring colour curve) and is added by the consumer + -- alongside this checkbox. + end + + if include.colorByType then + w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) + w.colorByType.hideOn = hideOff + end + + -- Two independent greys, both composed on top of whatever disableOn a control + -- already carries (e.g. the shadow sub-controls), and both leaving the + -- variant hideOn untouched: + -- disableWhen — the CONSUMER's gate: the feature this border belongs to is + -- switched off. Applies to EVERY widget including the Show Border + -- checkbox, since with the feature off there is nothing for it to show. + -- borderOff — Show Border itself is off. Applies to everything EXCEPT the + -- Show Border checkbox, which has to stay clickable to turn it back on. + -- RefreshChildStates applies disableOn to group children, and CreateCheckbox + -- auto-refreshes on toggle, so both greys update live. + for k, widget in pairs(w) do + if type(widget) == "table" and widget.SetEnabled then + local prev = widget.disableOn + local isShow = (k == "show") + widget.disableOn = function(d) + if disableWhen and disableWhen(dbTable) then return true end + if not isShow and borderOff() then return true end + return (prev and prev(d)) or false + end + end + end + + return w +end + +function GUI:CreateTextControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local onChange = opts.onChange + local onDrag = opts.onDrag or onChange + local L = DF.L + + local scaleMin, scaleMax, scaleStep = opts.scaleMin or 0.5, opts.scaleMax or 2.0, opts.scaleStep or 0.05 + local offMin, offMax = opts.offsetMin or -150, opts.offsetMax or 150 + + local function key(suffix) return prefix .. suffix end + local widgets = {} + + -- Apply the shared page gates to a widget, composing with any the widget factory set. + local function gate(w) + if opts.disableOn then + local prev = w.disableOn + w.disableOn = function(d) return (opts.disableOn(d) or (prev and prev(d))) and true or false end + end + if opts.hideOn then w.hideOn = opts.hideOn end + return w + end + + widgets.font = gate(group:AddWidget(GUI:CreateFontDropdown(parent, L["Font"], dbTable, key("Font"), onChange), 55)) + widgets.scale = gate(group:AddWidget(GUI:CreateSlider(parent, L["Scale"], scaleMin, scaleMax, scaleStep, dbTable, key("Scale"), nil, onDrag, true), 55)) + widgets.outline = gate(group:AddWidget(GUI:CreateOutlineDropdown(parent, L["Outline"], dbTable, key("Outline"), onChange), 55)) + widgets.shadow = gate(group:AddWidget(GUI:CreateShadowCheckbox(parent, L["Shadow"], dbTable, key("Outline"), onChange), 30)) + + if include.color then + widgets.color = gate(group:AddWidget(GUI:CreateColorPicker(parent, opts.colorLabel or L["Text Color"], dbTable, key("Color"), false, onChange, onDrag, true), 35)) + if opts.colorDisableOn then + local prev = widgets.color.disableOn + widgets.color.disableOn = function(d) return (opts.colorDisableOn(d) or (prev and prev(d))) and true or false end + end + end + + if include.anchor ~= false then + local anchorOptions = { + CENTER = L["Center"], TOP = L["Top"], BOTTOM = L["Bottom"], LEFT = L["Left"], RIGHT = L["Right"], + TOPLEFT = L["Top Left"], TOPRIGHT = L["Top Right"], BOTTOMLEFT = L["Bottom Left"], BOTTOMRIGHT = L["Bottom Right"], + } + widgets.anchor = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Anchor"], anchorOptions, dbTable, key("Anchor"), onChange), 55)) + -- Anchor vs Justify is the pair people get wrong: one places the text, + -- the other arranges it within its own box. Both say so, from their side. + widgets.anchor.tooltip = L["Which part of the element the text is pinned to. Offset X and Y then nudge it from there."] + end + + if include.offsets ~= false then + widgets.offsetX = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset X"], offMin, offMax, 1, dbTable, key("X"), nil, onDrag, true), 55)) + widgets.offsetY = gate(group:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], offMin, offMax, 1, dbTable, key("Y"), nil, onDrag, true), 55)) + end + + -- Justify is OPT-IN (include.justify = true). It's redundant with Anchor for short + -- single-token text on a small icon (duration/stacks) and boxing to justify TRUNCATES + -- wide text like "59m" — so the aura pages don't expose it. The DF.TextStyle engine + -- still honors JustifyH/JustifyV keys for a future wide/fixed-region consumer. + if include.justify then + local justifyHOptions = { [""] = L["Default"], LEFT = L["Left"], CENTER = L["Center"], RIGHT = L["Right"] } + local justifyVOptions = { [""] = L["Default"], TOP = L["Top"], MIDDLE = L["Middle"], BOTTOM = L["Bottom"] } + widgets.justifyH = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify H"], justifyHOptions, dbTable, key("JustifyH"), onChange), 55)) + widgets.justifyH.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] + widgets.justifyV = gate(group:AddWidget(GUI:CreateDropdown(parent, L["Justify V"], justifyVOptions, dbTable, key("JustifyV"), onChange), 55)) + widgets.justifyV.tooltip = L["How the text sits inside its own box, once Anchor has decided where that box goes. Only visible on text wide enough to have slack — Anchor is what moves it around the element."] + end + + return widgets +end + From 930ca09118a187cd60e320de5be76bf72b665526 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 21:04:05 +0100 Subject: [PATCH 24/34] Fix live tooltips breaking when the settings panel is unloaded GUI:ShowTooltip lived in the companion while its counterpart GUI:HideTooltip sat in the resident toolkit -- the pair split across an addon boundary by the original toolkit split. Live code calls ShowTooltip from pinned frames, the mover panel's "Reset Position to Global" button, the icon library and the settings search box, none of them guarded, so with the panel unloaded every one of those hovers errored. ShowTooltip, AddTooltipLines, the cursor-lift constants and INFO_BANNER_TONES (144 lines) move back to Widgets.lua. Tone colours belong in the toolkit anyway; the companion now aliases both from _priv instead of owning them. The profiler window is the other case. It is built from settings widgets, and CreateInfoBanner parents its hook checkbox, so there is nothing sensible to fall back to -- and moving that banner resident would cost ~500 lines to serve one debug panel. It calls DF:EnsureOptionsLoaded() instead: the window is opened deliberately, so pulling the companion in there costs nothing anyone will notice. None of this was reachable by any checker until lod_gate_check learned the GUI namespace in the previous commit. It also now recognises EnsureOptionsLoaded in the enclosing function as a guard in its own right -- it is not a nil-test, but after it every payload symbol is present, and treating only nil-tests as guards reported the profiler as a bug. Both arms negative-tested: deleting the load call turns it red again. lod_gate_check: 27 DF and 37 GUI call sites, 0 unguarded on both arms. --- DandersFrames/Debug/Profiler.lua | 9 ++ DandersFrames/GUI/Widgets.lua | 156 +++++++++++++++++++++++++ DandersFrames_Options/GUI/Sections.lua | 148 +---------------------- 3 files changed, 170 insertions(+), 143 deletions(-) diff --git a/DandersFrames/Debug/Profiler.lua b/DandersFrames/Debug/Profiler.lua index cc719a46..0d2c76bb 100644 --- a/DandersFrames/Debug/Profiler.lua +++ b/DandersFrames/Debug/Profiler.lua @@ -1749,6 +1749,15 @@ function Profiler:CreateUI() return end + -- ☠ This window is built from settings-panel widgets -- CreateInfoBanner in + -- particular, which parents the hook checkbox, so there is nothing sensible + -- to fall back to if it is missing. The profiler is opened deliberately, so + -- pulling the companion in here costs nothing anyone will notice and keeps + -- the panel whole. Same call the settings panel itself uses. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then + return + end + -- Main frame local f = CreateFrame("Frame", "DFProfilerFrame", UIParent, "BackdropTemplate") -- Bottom slack has to clear TWO stacked things, not one: diff --git a/DandersFrames/GUI/Widgets.lua b/DandersFrames/GUI/Widgets.lua index 3eb9f53f..f30c45aa 100644 --- a/DandersFrames/GUI/Widgets.lua +++ b/DandersFrames/GUI/Widgets.lua @@ -2409,6 +2409,162 @@ local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "M -- Returns the created widgets keyed { font, scale, outline, shadow, color, anchor, -- offsetX, offsetY, justifyH, justifyV } so pages can attach extra gates. -- ============================================================ +-- ============================================================ +-- TOOLTIPS AND BANNER TONES (moved back from the companion) +-- ============================================================ +-- ☠ These lived in DandersFrames_Options/GUI/Sections.lua while HideTooltip +-- sat here -- the pair split across an addon boundary. Live code calls +-- ShowTooltip from pinned frames, the mover panel and the icon library, so +-- with the settings panel unloaded every one of those hovers errored. Found +-- by teaching lod_gate_check.py the GUI namespace; nothing else could see it. + +-- ============================================================ +-- CreateInfoBanner +-- ------------------------------------------------------------ +-- A self-resizing banner with an icon, body text, and a "tone" +-- (info / caution / danger / success) that controls background, +-- border, default text colour, and default icon. ("warning" is a +-- legacy alias of "caution".) Do NOT pass fontTemplate — banners +-- share one font on purpose; only the tone should vary. +-- +-- Usage: +-- local banner = GUI:CreateInfoBanner(parent, { tone = "caution", text = "..." }) +-- Add(banner, banner.layoutHeight, "both") +-- +-- Methods on the returned frame: +-- :SetTone(name) apply a preset (see TONES below) +-- :SetText(text, optColor) plain text mode, auto-wraps + auto-resizes +-- :SetHTML(html, onLinkClick) flow-layout body with clickable link buttons +-- :SetIcon(texture, r, g, b) icon texture + optional vertex colour +-- :SetIconTexture(path) icon texture only +-- :SetIconColor(r, g, b) icon vertex colour only +-- +-- The body word-wraps automatically; banner height is recomputed via +-- OnSizeChanged so resizing the GUI (or calling SetText/SetHTML) grows +-- or shrinks the banner to fit. The host page is re-laid out so widgets +-- below the banner reposition. +-- ============================================================ +-- Each tone carries FOUR colour roles so all three consumers (banners, +-- inline ToneHex text, and tooltips) stay in sync: +-- bg / border / textColor / icon+iconColor drive the BANNER box itself. +-- accent is the vivid emphasis colour +-- used for INLINE text (ToneHex) and tooltip titles, read against the +-- dark GUI background rather than the banner's own tinted bg. It is a +-- SEPARATE role from iconColor: e.g. the danger icon is a light warm so +-- the triangle pops on the orange banner, but inline "Warning" text must +-- be a real red to out-rank a caution — deriving one from the other +-- (the original bug) made inline danger paler than caution. +local INFO_BANNER_TONES = { + info = { + bg = {0.15, 0.18, 0.28, 1}, + useThemeBorder = true, borderAlpha = 0.5, + icon = "info", + textColor = {0.85, 0.85, 0.85}, + accent = {0.6, 0.8, 1}, -- light blue + }, + -- NOTE: "warning" was merged into "caution" (they were near-duplicate golds). + -- SetTone("warning") still resolves via the alias below for safety. + caution = { + bg = {0.5, 0.45, 0.1, 0.9}, + border = {0.7, 0.6, 0.1, 1}, + icon = "warning", iconColor = {1, 0.9, 0.3}, + textColor = {1, 0.95, 0.7}, + accent = {1, 0.82, 0}, -- gold + }, + danger = { + bg = {0.6, 0.3, 0.1, 0.9}, + border = {0.8, 0.4, 0.1, 1}, + -- icon kept a light warm (not the mid-orange bg hue) so the triangle pops + icon = "warning", iconColor = {1, 0.9, 0.72}, + textColor = {1, 0.85, 0.7}, + accent = {1, 0.27, 0.27}, -- real red (destructive), NOT the pale icon warm + }, + success = { + bg = {0.1, 0.4, 0.2, 0.9}, + border = {0.2, 0.6, 0.3, 1}, + icon = "check", iconColor = {0.3, 1, 0.5}, + textColor = {0.7, 1, 0.8}, + accent = {0.4, 0.85, 0.5}, -- green + }, +} +-- Legacy alias: "warning" was merged into "caution" (near-duplicate golds). +INFO_BANNER_TONES.warning = INFO_BANNER_TONES.caution +P.INFO_BANNER_TONES = INFO_BANNER_TONES + +-- The line grammar, shared by ShowTooltip and ShowGameTooltip so a DF line +-- appended under a spell tooltip reads exactly like one under a plain title. +local function AddTooltipLines(lines) + if not lines then return end + local acc + for _, line in ipairs(lines) do + if line == " " or line == "" then + GameTooltip:AddLine(" ") + elseif type(line) == "string" then + GameTooltip:AddLine(line, 0.7, 0.7, 0.7, true) + elseif type(line) == "table" and (line.text or line.left) then + local r, g, b = 0.7, 0.7, 0.7 + if line.hint then + r, g, b = 0.55, 0.55, 0.55 + elseif line.accent then + acc = acc or GetThemeColor() + r, g, b = acc.r, acc.g, acc.b + elseif line.color then + -- Accept {r=,g=,b=} or {r,g,b}: the palette uses the first, most + -- call sites building a colour inline reach for the second. + local c = line.color + r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] + end + if line.left then + -- Two-column form (label … value), for key/value dumps. Never + -- wraps -- AddDoubleLine has no wrap argument. + GameTooltip:AddDoubleLine(line.left, line.right, r, g, b, 1, 1, 1) + else + GameTooltip:AddLine(line.text, r, g, b, true) + end + end + end +end + +-- How far the cursor-anchored tooltip is nudged off the pointer. ONE dial. +-- +-- Small on purpose. This started at 24 while the tooltip could still appear over +-- a slider or dropdown, where it had to clear the whole control. Now that the +-- hover lives on the LABEL only (see GUI:AttachTooltip) there is nothing +-- underneath worth clearing — the lift just has to keep the tooltip off the +-- words you are reading, so a nudge does it. +-- +-- ⚠ ANCHOR_CURSOR_RIGHT, not ANCHOR_CURSOR — plain ANCHOR_CURSOR DISCARDS the +-- offsets. Verified against the client's own code rather than assumed: +-- Blizzard_AuraContainer/Blizzard_AuraButton.lua asserts the signature +-- SetOwner(point, offsetX, offsetY) with both offsets optional numbers, and +-- Blizzard's own callers only ever pass offsets alongside the _RIGHT / _LEFT +-- variants (QuestDataProvider "ANCHOR_CURSOR_RIGHT", 5, 2 — never with the plain +-- cursor anchor). Positive Y is up in WoW, so this lifts regardless of which +-- edge the anchor pins. +local CURSOR_LIFT_X, CURSOR_LIFT_Y = 0, 8 + +function GUI:ShowTooltip(owner, opts) + if not owner or not opts or not opts.title then return end + if opts.anchor then + GameTooltip:SetOwner(owner, opts.anchor) + else + GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT", CURSOR_LIFT_X, CURSOR_LIFT_Y) + end + -- Title colour is single-sourced from the tone's inline accent so a tooltip + -- title reads the same as inline ToneHex text of the same tone. Untoned = white. + local toneDef = opts.tone and INFO_BANNER_TONES[opts.tone] + local ac = toneDef and toneDef.accent + if ac then + GameTooltip:SetText(opts.title, ac[1], ac[2], ac[3]) + else + GameTooltip:SetText(opts.title, 1, 1, 1) + end + AddTooltipLines(opts.lines) + GameTooltip:Show() +end + +P.AddTooltipLines = AddTooltipLines + -- ============================================================ -- SHARED WITH THE SETTINGS-PANEL WIDGETS diff --git a/DandersFrames_Options/GUI/Sections.lua b/DandersFrames_Options/GUI/Sections.lua index 8dc76cf0..b595e7a5 100644 --- a/DandersFrames_Options/GUI/Sections.lua +++ b/DandersFrames_Options/GUI/Sections.lua @@ -15,6 +15,11 @@ local ResolveRowHeight = GUI.ResolveRowHeight local GetThemeColor = GUI.GetThemeColor local SnapLen = GUI.SnapLen local CreateElementBackdrop = GUI._priv.CreateElementBackdrop +-- Tooltip primitives and the tone palette are resident: live frames need them +-- with this addon unloaded. Aliases of those objects, not copies. +local INFO_BANNER_TONES = P.INFO_BANNER_TONES +local AddTooltipLines = P.AddTooltipLines + function GUI:CreateSettingsGroup(parent, width, opts) -- opts can be a boolean (legacy: collapsible) or a table { collapsible, showSummary, onCollapseChanged } @@ -502,78 +507,6 @@ function GUI:LinkHoverColor(c) return { r = c.r + (1 - c.r) * t, g = c.g + (1 - c.g) * t, b = c.b + (1 - c.b) * t } end --- ============================================================ --- CreateInfoBanner --- ------------------------------------------------------------ --- A self-resizing banner with an icon, body text, and a "tone" --- (info / caution / danger / success) that controls background, --- border, default text colour, and default icon. ("warning" is a --- legacy alias of "caution".) Do NOT pass fontTemplate — banners --- share one font on purpose; only the tone should vary. --- --- Usage: --- local banner = GUI:CreateInfoBanner(parent, { tone = "caution", text = "..." }) --- Add(banner, banner.layoutHeight, "both") --- --- Methods on the returned frame: --- :SetTone(name) apply a preset (see TONES below) --- :SetText(text, optColor) plain text mode, auto-wraps + auto-resizes --- :SetHTML(html, onLinkClick) flow-layout body with clickable link buttons --- :SetIcon(texture, r, g, b) icon texture + optional vertex colour --- :SetIconTexture(path) icon texture only --- :SetIconColor(r, g, b) icon vertex colour only --- --- The body word-wraps automatically; banner height is recomputed via --- OnSizeChanged so resizing the GUI (or calling SetText/SetHTML) grows --- or shrinks the banner to fit. The host page is re-laid out so widgets --- below the banner reposition. --- ============================================================ --- Each tone carries FOUR colour roles so all three consumers (banners, --- inline ToneHex text, and tooltips) stay in sync: --- bg / border / textColor / icon+iconColor drive the BANNER box itself. --- accent is the vivid emphasis colour --- used for INLINE text (ToneHex) and tooltip titles, read against the --- dark GUI background rather than the banner's own tinted bg. It is a --- SEPARATE role from iconColor: e.g. the danger icon is a light warm so --- the triangle pops on the orange banner, but inline "Warning" text must --- be a real red to out-rank a caution — deriving one from the other --- (the original bug) made inline danger paler than caution. -local INFO_BANNER_TONES = { - info = { - bg = {0.15, 0.18, 0.28, 1}, - useThemeBorder = true, borderAlpha = 0.5, - icon = "info", - textColor = {0.85, 0.85, 0.85}, - accent = {0.6, 0.8, 1}, -- light blue - }, - -- NOTE: "warning" was merged into "caution" (they were near-duplicate golds). - -- SetTone("warning") still resolves via the alias below for safety. - caution = { - bg = {0.5, 0.45, 0.1, 0.9}, - border = {0.7, 0.6, 0.1, 1}, - icon = "warning", iconColor = {1, 0.9, 0.3}, - textColor = {1, 0.95, 0.7}, - accent = {1, 0.82, 0}, -- gold - }, - danger = { - bg = {0.6, 0.3, 0.1, 0.9}, - border = {0.8, 0.4, 0.1, 1}, - -- icon kept a light warm (not the mid-orange bg hue) so the triangle pops - icon = "warning", iconColor = {1, 0.9, 0.72}, - textColor = {1, 0.85, 0.7}, - accent = {1, 0.27, 0.27}, -- real red (destructive), NOT the pale icon warm - }, - success = { - bg = {0.1, 0.4, 0.2, 0.9}, - border = {0.2, 0.6, 0.3, 1}, - icon = "check", iconColor = {0.3, 1, 0.5}, - textColor = {0.7, 1, 0.8}, - accent = {0.4, 0.85, 0.5}, -- green - }, -} --- Legacy alias: "warning" was merged into "caution" (near-duplicate golds). -INFO_BANNER_TONES.warning = INFO_BANNER_TONES.caution -P.INFO_BANNER_TONES = INFO_BANNER_TONES -- Hex accent ("ffRRGGBB", for inline |c...|r escapes) matching a banner tone, so -- inline caveat text (e.g. a warning word in a subtitle) reads as the SAME @@ -1438,77 +1371,6 @@ end -- { text = , accent = true } -> mode/context accent colour, wrapped -- { text = , color = {r,g,b} } -> explicit colour, wrapped -- ============================================================ --- The line grammar, shared by ShowTooltip and ShowGameTooltip so a DF line --- appended under a spell tooltip reads exactly like one under a plain title. -local function AddTooltipLines(lines) - if not lines then return end - local acc - for _, line in ipairs(lines) do - if line == " " or line == "" then - GameTooltip:AddLine(" ") - elseif type(line) == "string" then - GameTooltip:AddLine(line, 0.7, 0.7, 0.7, true) - elseif type(line) == "table" and (line.text or line.left) then - local r, g, b = 0.7, 0.7, 0.7 - if line.hint then - r, g, b = 0.55, 0.55, 0.55 - elseif line.accent then - acc = acc or GetThemeColor() - r, g, b = acc.r, acc.g, acc.b - elseif line.color then - -- Accept {r=,g=,b=} or {r,g,b}: the palette uses the first, most - -- call sites building a colour inline reach for the second. - local c = line.color - r, g, b = c.r or c[1], c.g or c[2], c.b or c[3] - end - if line.left then - -- Two-column form (label … value), for key/value dumps. Never - -- wraps -- AddDoubleLine has no wrap argument. - GameTooltip:AddDoubleLine(line.left, line.right, r, g, b, 1, 1, 1) - else - GameTooltip:AddLine(line.text, r, g, b, true) - end - end - end -end - --- How far the cursor-anchored tooltip is nudged off the pointer. ONE dial. --- --- Small on purpose. This started at 24 while the tooltip could still appear over --- a slider or dropdown, where it had to clear the whole control. Now that the --- hover lives on the LABEL only (see GUI:AttachTooltip) there is nothing --- underneath worth clearing — the lift just has to keep the tooltip off the --- words you are reading, so a nudge does it. --- --- ⚠ ANCHOR_CURSOR_RIGHT, not ANCHOR_CURSOR — plain ANCHOR_CURSOR DISCARDS the --- offsets. Verified against the client's own code rather than assumed: --- Blizzard_AuraContainer/Blizzard_AuraButton.lua asserts the signature --- SetOwner(point, offsetX, offsetY) with both offsets optional numbers, and --- Blizzard's own callers only ever pass offsets alongside the _RIGHT / _LEFT --- variants (QuestDataProvider "ANCHOR_CURSOR_RIGHT", 5, 2 — never with the plain --- cursor anchor). Positive Y is up in WoW, so this lifts regardless of which --- edge the anchor pins. -local CURSOR_LIFT_X, CURSOR_LIFT_Y = 0, 8 - -function GUI:ShowTooltip(owner, opts) - if not owner or not opts or not opts.title then return end - if opts.anchor then - GameTooltip:SetOwner(owner, opts.anchor) - else - GameTooltip:SetOwner(owner, "ANCHOR_CURSOR_RIGHT", CURSOR_LIFT_X, CURSOR_LIFT_Y) - end - -- Title colour is single-sourced from the tone's inline accent so a tooltip - -- title reads the same as inline ToneHex text of the same tone. Untoned = white. - local toneDef = opts.tone and INFO_BANNER_TONES[opts.tone] - local ac = toneDef and toneDef.accent - if ac then - GameTooltip:SetText(opts.title, ac[1], ac[2], ac[3]) - else - GameTooltip:SetText(opts.title, 1, 1, 1) - end - AddTooltipLines(opts.lines) - GameTooltip:Show() -end -- ============================================================ -- GAME-DATA TOOLTIP (a spell / item / equipped item / aura, plus our own lines) From 3340d786175dc5b2b88ea1f5392b73b528d36971 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 21:14:16 +0100 Subject: [PATCH 25/34] Move settings search into the companion Search was resident so that it could trigger the companion load. It cannot need to: it has no entry point of its own. No slash command, no keybind -- the search bar is built by GUI/Panel.lua, which is itself in the companion, so search is only ever reachable with the panel already open. That makes the planned "make search load the companion" change unnecessary and the file itself a clean move. 1,123 lines, no events registered, and the only file-scope statement is DF.Search = Search. Its two resident callers are the search-registration lines inside CreateSlider and CreateDropdown, both already nil-guarded. With the panel unloaded they skip, which is right -- the sliders being created at that point belong to the mover panel and are not settings entries. Once the panel loads, the settings sliders it builds register normally. Payload is now 56,553 of 158,784 lines, 35.6%. --- DandersFrames/DandersFrames.toc | 1 - DandersFrames_Options/DandersFrames_Options.toc | 3 +++ {DandersFrames => DandersFrames_Options}/Features/Search.lua | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) rename {DandersFrames => DandersFrames_Options}/Features/Search.lua (99%) diff --git a/DandersFrames/DandersFrames.toc b/DandersFrames/DandersFrames.toc index 7131bc7e..b596b28d 100644 --- a/DandersFrames/DandersFrames.toc +++ b/DandersFrames/DandersFrames.toc @@ -86,7 +86,6 @@ GUI\DFFonts.lua # settings panel, both designers, the click-casting UI, test mode and the debug # tools all live in that companion now. GUI\LoadOptions.lua -Features\Search.lua # Popup System (alert / confirm / input dialogs) GUI\Popup.lua diff --git a/DandersFrames_Options/DandersFrames_Options.toc b/DandersFrames_Options/DandersFrames_Options.toc index a213d637..647d711a 100644 --- a/DandersFrames_Options/DandersFrames_Options.toc +++ b/DandersFrames_Options/DandersFrames_Options.toc @@ -33,6 +33,9 @@ # Widget factories the live addon never calls, lifted out of the resident # GUI.lua / Widgets.lua. Loads first: the parts below build pages with them. GUI\SettingsWidgets.lua +# Settings search. It has no entry point of its own -- the search bar is built +# by GUI\Panel.lua -- so it is only ever reachable with this addon loaded. +Features\Search.lua GUI\Sections.lua GUI\Controls.lua GUI\Panel.lua diff --git a/DandersFrames/Features/Search.lua b/DandersFrames_Options/Features/Search.lua similarity index 99% rename from DandersFrames/Features/Search.lua rename to DandersFrames_Options/Features/Search.lua index 8efd1e6c..771f85b4 100644 --- a/DandersFrames/Features/Search.lua +++ b/DandersFrames_Options/Features/Search.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, +-- so every DF.* read here would be nil. DandersFrames publishes its own table +-- as a global via ## AllowAddOnTableAccess -- take it from there. +local DF = DandersFrames -- ============================================================ -- DANDERSFRAMES SEARCH SYSTEM From b2a2372cbfe91dbe779c5be7a1a6b347a0cf0c05 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 21:49:29 +0100 Subject: [PATCH 26/34] Fix everything that stopped working when the settings panel was not loaded An adversarial audit of the reorg (five independent sweeps) found the reorg plumbing itself clean but a set of features quietly broken for anyone who had not opened /df that session -- almost all of them calls into the companion from resident event code, in namespaces the checker did not watch. Worst first: - Click-casting UI refreshes errored on every talent swap, level-up and spec change: four resident sites called CC:RefreshClickCastingUI (companion-only) from DeferAfter timers, which run callbacks raw. They now route through CC:RefreshUIIfLoaded, a guarded helper -- a refresh with no UI is correctly a no-op, the panel rebuilds from current state when it loads. - The Clique/Clicked conflict popup was a Lua error 1.5s after login (and on a profile switch that enables click-casting), and the warning it exists to show never appeared. Both sites now load the companion first: it is a modal the user must act on, and only users with a conflicting addon pay. - /df raidunlock crashed halfway: ShowRaidTestFrames was the one TestMode entry point without a Shim stub. Stub added, and both UnlockFrames and UnlockRaidFrames now load the companion up front -- unlocking exists to show test frames, so the party path's silent "movers with no frames in them" was no better than the raid path's crash. - /df test was a silent no-op behind a nil-guard. It, the mover Toggle Test action, and /df testids now load the companion: deliberate user commands follow the profiler's pattern, background events guard. - /df debug cc spells errored with "click-casting module not loaded" -- false (only the UI half is not) -- and never self-healed. The dispatcher now loads the companion and retries the handler lookup once. The generic /df debug dispatcher does the same before falling through, so the companion's four dev slashes (memtest, atlas, auraexp, colorhook) work on first use instead of opening the settings window. - The colour-picker global override reverted to the stock picker every session: its hook installs at companion load. LoadOptions.lua now loads the companion at login when the setting is enabled -- only users who opted into the override pay, and what they pay for is the feature working. lod_gate_check.py grew a CC arm (the click-casting table alias both sides use), which is how every one of these would have been caught; verified by reverting one site to its pre-fix shape and watching it report. Known blind spot documented: self: calls inside a resident method of a shared table cannot be attributed textually -- keep cross-boundary calls on the table name. --- DandersFrames/ClickCasting/Bindings.lua | 9 ++++++++- DandersFrames/ClickCasting/Events.lua | 26 +++++++++++++++++++------ DandersFrames/ClickCasting/Frames.lua | 8 ++++++++ DandersFrames/ClickCasting/Profiles.lua | 6 ++++++ DandersFrames/Core.lua | 16 +++++++++++++++ DandersFrames/Frames/Init.lua | 10 +++++++++- DandersFrames/Frames/Position.lua | 15 +++++++++++++- DandersFrames/GUI/LoadOptions.lua | 21 ++++++++++++++++++++ DandersFrames/TestMode/Shim.lua | 1 + 9 files changed, 103 insertions(+), 9 deletions(-) diff --git a/DandersFrames/ClickCasting/Bindings.lua b/DandersFrames/ClickCasting/Bindings.lua index 71970fb3..dbebf784 100644 --- a/DandersFrames/ClickCasting/Bindings.lua +++ b/DandersFrames/ClickCasting/Bindings.lua @@ -3787,11 +3787,18 @@ SlashCmdList["DFCC"] = function(msg) -- user was never shown. Fall through to "unknown" rather than -- reporting "dev only": a hidden command must not advertise itself. if e[1] == word and (not e[3] or dev) then + -- The handlers register when the companion loads. A deliberate + -- command -> load it, then retry the lookup. The old branch + -- errored with "click-casting module not loaded", which was + -- false (only the UI wasn't) and never self-healed. local handler = SlashCmdList[e[2]] + if not handler and DF.EnsureOptionsLoaded and DF:EnsureOptionsLoaded() then + handler = SlashCmdList[e[2]] + end if handler then handler(rest or "") else - DF:Err("click-casting module not loaded") + DF:Err("click-casting UI unavailable") end return end diff --git a/DandersFrames/ClickCasting/Events.lua b/DandersFrames/ClickCasting/Events.lua index 8342e586..738771ec 100644 --- a/DandersFrames/ClickCasting/Events.lua +++ b/DandersFrames/ClickCasting/Events.lua @@ -66,7 +66,7 @@ function CC:RegisterEvents() CC:ApplyBindings() -- Also refresh UI in case talents changed CC:DeferAfter("uiRefresh", 0.3, function() - CC:RefreshClickCastingUI() + CC:RefreshUIIfLoaded() end) end) else @@ -78,14 +78,16 @@ function CC:RegisterEvents() if not InCombatLockdown() then CC:ApplyBindings() CC:DeferAfter("uiRefresh", 0.2, function() - CC:RefreshClickCastingUI() + CC:RefreshUIIfLoaded() end) else CC:Defer("bindingRefresh") end elseif event == "PLAYER_EQUIPMENT_CHANGED" then - -- Equipment changed - refresh items tab if visible - if CC.activeTab == "items" then + -- Equipment changed - refresh items tab if visible. activeTab is only + -- ever set by the companion, but guard the method explicitly rather + -- than lean on that invariant. + if CC.activeTab == "items" and CC.RefreshSpellGrid then CC:RefreshSpellGrid() end elseif event == "PLAYER_ENTERING_WORLD" then @@ -360,7 +362,7 @@ function CC:DrainDeferred(onlyJob) -- Refresh UI if needed (after a short delay for everything to settle) if needsUIRefresh then self:DeferAfter("uiRefresh", 0.2, function() - CC:RefreshClickCastingUI() + CC:RefreshUIIfLoaded() end) end end @@ -377,6 +379,18 @@ end -- DeferAfter keys each timer: scheduling the same key again cancels the -- pending one, so there is always at most one run in flight per key. +-- ☠ The click-casting UI lives in the load-on-demand companion; this file is +-- resident. Every "refresh the panel" reaction to a talent/spec/level event +-- must go through this guard: with the panel never opened the method is nil, +-- and DeferAfter runs its callback raw inside a C_Timer, so an unguarded call +-- is a Lua error on every talent swap. A refresh with no UI is correctly a +-- no-op -- the panel builds itself from current state when it loads. +function CC:RefreshUIIfLoaded() + if self.RefreshClickCastingUI then + self:RefreshClickCastingUI() + end +end + function CC:DeferAfter(key, delay, fn) self.timers = self.timers or {} local existing = self.timers[key] @@ -412,7 +426,7 @@ function CC:OnSpecChanged() self:ApplyBindings() -- Refresh UI after a short delay to ensure spell data is ready self:DeferAfter("uiRefresh", 0.3, function() - CC:RefreshClickCastingUI() + CC:RefreshUIIfLoaded() end) else self:Defer("loadoutCheck") diff --git a/DandersFrames/ClickCasting/Frames.lua b/DandersFrames/ClickCasting/Frames.lua index b81ead77..d84f1adb 100755 --- a/DandersFrames/ClickCasting/Frames.lua +++ b/DandersFrames/ClickCasting/Frames.lua @@ -348,6 +348,14 @@ function CC:InitializeSecureFrames() -- Delay slightly to ensure UI is ready C_Timer.After(1.5, function() if CC.db.enabled and CC.hasConflictingAddons then + -- ☠ The popup lives in the load-on-demand companion; this fires at + -- login before anyone has opened the panel. It is a modal warning + -- the user must act on (Clique/Clicked conflict), so load the + -- companion for it rather than silently skip -- this only costs + -- users who actually have a conflicting addon installed. + if not CC.ShowClickCastConflictPopup then + if not (DF.EnsureOptionsLoaded and DF:EnsureOptionsLoaded()) then return end + end CC:ShowClickCastConflictPopup(CC.conflictingAddons, CC.enableCb) end end) diff --git a/DandersFrames/ClickCasting/Profiles.lua b/DandersFrames/ClickCasting/Profiles.lua index e0a76b0e..cf678201 100644 --- a/DandersFrames/ClickCasting/Profiles.lua +++ b/DandersFrames/ClickCasting/Profiles.lua @@ -353,6 +353,12 @@ function CC:SetActiveProfile(profileName) if self.hasConflictingAddons and self.conflictingAddons then C_Timer.After(0.1, function() if CC.db.enabled and CC.hasConflictingAddons then + -- ☠ Popup lives in the companion; profile switches are + -- reachable without the panel (slash, permanent movers, + -- loadout auto-switch). Modal warning -> load, don't skip. + if not CC.ShowClickCastConflictPopup then + if not (DF.EnsureOptionsLoaded and DF:EnsureOptionsLoaded()) then return end + end CC:ShowClickCastConflictPopup(CC.conflictingAddons, CC.enableCb) end end) diff --git a/DandersFrames/Core.lua b/DandersFrames/Core.lua index d47f69cc..61382ca8 100644 --- a/DandersFrames/Core.lua +++ b/DandersFrames/Core.lua @@ -4942,6 +4942,15 @@ DF._MainEventDispatcher = function(self, event, arg1) -- "on"/"off" are the logging toggle, not commands named on/off. if dbgWord and dbgWord:lower() ~= "on" and dbgWord:lower() ~= "off" then local dbgKey = DF.DebugSlashBySub[dbgWord:lower()] + -- Several debug tools live in the companion and register their + -- slashes only when it loads. If the word is unknown and the + -- companion is not in yet, load it and retry once -- otherwise + -- the first use of /df debug memtest fell through to the final + -- else and opened the settings window instead of the tool. + if not dbgKey and not DF._optionsAddonLoaded + and DF.EnsureOptionsLoaded and DF:EnsureOptionsLoaded() then + dbgKey = DF.DebugSlashBySub[dbgWord:lower()] + end if dbgKey and SlashCmdList[dbgKey] then SlashCmdList[dbgKey](dbgRest or "") else @@ -5216,6 +5225,10 @@ DF._MainEventDispatcher = function(self, event, arg1) -- is generated and cannot drift instead of duplicating a subset -- of it by hand. elseif msg == "test" then + -- The test panel lives in the load-on-demand companion. + -- Deliberate user command -> load it; the old nil-guard made + -- /df test a silent no-op until the settings panel was opened. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then return end if DF.ToggleTestPanel then DF:ToggleTestPanel() end elseif msg == "hide" then if DF.HideTestFrames then DF:HideTestFrames() end @@ -5520,6 +5533,9 @@ DF._MainEventDispatcher = function(self, event, arg1) -- can find the current ID (for re-hardcoding after reshuffles). -- Results also land in the SavedVariables root (flushed by the -- next /reload) so they can be read from disk — no chat copy. + -- DF.TestData lives in the companion; load it or bail. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then return end + if not DF.TestData then DF:Err("test data unavailable") return end local dump = {} local o = DF:Out("Test IDs", "spell-ID audit") for _, poolName in ipairs({ "buffs", "debuffs" }) do diff --git a/DandersFrames/Frames/Init.lua b/DandersFrames/Frames/Init.lua index 370c4f36..85ae4352 100644 --- a/DandersFrames/Frames/Init.lua +++ b/DandersFrames/Frames/Init.lua @@ -868,7 +868,15 @@ function DF:UnlockRaidFrames() DF:Err("Cannot unlock raid frames during combat.") return end - + + -- Unlocking shows raid test frames, which live in the load-on-demand + -- companion. Deliberate user action (/df raidunlock, mover button), so + -- load it -- without this, ShowRaidTestFrames was a nil call and the + -- unlock aborted halfway: grid shown, movers never set up. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then + return + end + if not DF.raidContainer then DF:Err("Cannot unlock - raid container doesn't exist!") return diff --git a/DandersFrames/Frames/Position.lua b/DandersFrames/Frames/Position.lua index ed9dfd6b..0de06b57 100644 --- a/DandersFrames/Frames/Position.lua +++ b/DandersFrames/Frames/Position.lua @@ -404,7 +404,12 @@ local function RefreshPermMoverActions() UNLOCK_FRAMES = { label = L["Unlock Frames"], combatSafe = false, fn = function(mode) if mode == "raid" then DF:UnlockRaidFrames() else DF:UnlockFrames() end end }, - TOGGLE_TEST = { label = L["Toggle Test Mode"], combatSafe = false, fn = function() if DF.ToggleTestMode then DF:ToggleTestMode() end end }, + TOGGLE_TEST = { label = L["Toggle Test Mode"], combatSafe = false, fn = function() + -- Test mode lives in the companion; a mover button is a deliberate + -- action, so load it rather than silently do nothing. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then return end + if DF.ToggleTestMode then DF:ToggleTestMode() end + end }, SWITCH_PROFILE = { label = L["Quick Switch Profile"], combatSafe = false, fn = function(mode, handle) DF:ShowPermanentMoverProfilePopup(handle) end }, SWITCH_CC_PROFILE = { label = L["Quick Switch CC Profile"], combatSafe = false, fn = function(mode, handle) DF:ShowPermanentMoverCCProfilePopup(handle) end }, CYCLE_PROFILE = { label = L["Cycle Next Profile"], combatSafe = false, fn = function() DF:CycleNextProfile() end }, @@ -2343,6 +2348,14 @@ function DF:UnlockFrames() return end + -- Unlocking shows test frames, and test mode lives in the load-on-demand + -- companion. This is a deliberate user action (/df unlock, mover button), + -- so load the companion rather than let the shim no-op leave the movers up + -- with no frames in them. Failure already reported by Ensure. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then + return + end + local db = DF:GetDB() -- Ensure container exists diff --git a/DandersFrames/GUI/LoadOptions.lua b/DandersFrames/GUI/LoadOptions.lua index 0b80ea63..7d46f56a 100644 --- a/DandersFrames/GUI/LoadOptions.lua +++ b/DandersFrames/GUI/LoadOptions.lua @@ -71,3 +71,24 @@ toggleStub = function() return DF:ToggleGUI() end DF.ToggleGUI = toggleStub + +-- ============================================================ +-- COLOUR-PICKER GLOBAL OVERRIDE BOOTSTRAP +-- ============================================================ +-- The setting promises DF's colour picker replaces Blizzard's EVERYWHERE -- +-- including pickers opened by other addons and the stock UI. The hook that +-- delivers it installs at companion load, so for a user who enabled it the +-- feature silently reverted to the stock picker every session until they +-- opened /df once. If the setting is on, load the companion at login: only +-- users who opted into the override pay, and what they pay for is the +-- feature actually working. +local pickerBoot = CreateFrame("Frame") +pickerBoot:RegisterEvent("PLAYER_ENTERING_WORLD") +pickerBoot:SetScript("OnEvent", function(self, _, isInitialLogin, isReloadingUi) + if not (isInitialLogin or isReloadingUi) then return end + self:UnregisterEvent("PLAYER_ENTERING_WORLD") + local db = DF.db and DF.db.party + if db and db.colorPickerGlobalOverride then + DF:EnsureOptionsLoaded() + end +end) diff --git a/DandersFrames/TestMode/Shim.lua b/DandersFrames/TestMode/Shim.lua index 76a0f12b..d908a9fb 100644 --- a/DandersFrames/TestMode/Shim.lua +++ b/DandersFrames/TestMode/Shim.lua @@ -41,6 +41,7 @@ local function noop() end if not DF.GetTestUnitData then DF.GetTestUnitData = function() return nil end end if not DF.ShowTestFrames then DF.ShowTestFrames = noop end +if not DF.ShowRaidTestFrames then DF.ShowRaidTestFrames = noop end if not DF.HideTestFrames then DF.HideTestFrames = noop end if not DF.RefreshTestFrames then DF.RefreshTestFrames = noop end if not DF.UpdateTestFrame then DF.UpdateTestFrame = noop end From b080e81f70bd82089b640b9b4b90c112c7ef81b6 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 22:09:24 +0100 Subject: [PATCH 27/34] Move four settings-only blocks to the companion (~290 KB resident) The audit sized what was left after the big moves; these four were the whole of the low-risk remainder. 1. The changelog text (~180 KB). Core/Changelog.lua was one giant string literal held resident forever, read only by the panel's changelog tab. The resident file now carries just the CI-stamped BUILD_DATE/RELEASE_CHANNEL header; the text is a second GENERATED file in the companion. generate_changelog.sh writes both (verified end-to-end with OVERRIDE_TAG; plain local runs die on a pre-existing sed when git describe resolves to a slash-named backup tag -- CI only sees release tags). release.yml's build-skip filter lists both generated paths; its RELEASE_CHANNEL grep still reads the resident header. 2. Core/ExportCategories.lua (~85 KB of category tables). Every consumer is a selective import/export path. The three resident entry points in Core/Profile.lua load the companion on demand, each scoped so the cheap case stays cheap: ExportProfile only when categories were requested (the external DandersFrames_Export API does full exports and never pays), ApplyImportedProfile only for selective payloads, GetImportInfo always (it iterates the tables unconditionally). /df exportaudit loads too. 3. The profiler window (642 lines -> Debug/ProfilerUI.lua). The hooks, recording engine and chat reports stay resident. NOT a pure move, and the commit says so: the halves shared two mutable locals (profilerFrame, UpdateUI), which cannot be aliased across an addon boundary. The window now publishes Profiler.frame and Profiler.UpdateUI; the six resident read sites -- combat auto-profile, QuickProfile, the SetScript hook's self-exclusion -- go through those, nil-guarded, where nil honestly means "no window exists". The six number formatters are published on Profiler so the window renders with exactly what the chat reports use. Profiler.ToggleUI is the same load-and-redispatch stub as DF.ToggleGUI, loop guard included. 4. GUI/IconLib.lua (/dficons dev icon browser, 223 lines). Zero external references; its slash re-registers on companion load, and the /df debug dispatcher's load-and-retry covers first use. Payload is now 59,085 of 157,693 lines -- 37.5%. --- .github/workflows/release.yml | 2 +- DandersFrames/Core.lua | 2 + DandersFrames/Core/Changelog.lua | 1501 +------ DandersFrames/Core/Profile.lua | 22 +- DandersFrames/DandersFrames.toc | 2 - DandersFrames/Debug/Profiler.lua | 3732 +++++++---------- DandersFrames_Options/Changelog.lua | 222 + .../Core/ExportCategories.lua | 5 +- .../DandersFrames_Options.toc | 13 + DandersFrames_Options/Debug/ProfilerUI.lua | 672 +++ .../GUI/IconLib.lua | 3 +- generate_changelog.sh | 23 +- 12 files changed, 2522 insertions(+), 3677 deletions(-) create mode 100644 DandersFrames_Options/Changelog.lua rename {DandersFrames => DandersFrames_Options}/Core/ExportCategories.lua (99%) create mode 100644 DandersFrames_Options/Debug/ProfilerUI.lua rename {DandersFrames => DandersFrames_Options}/GUI/IconLib.lua (98%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1d739cc..d51038ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: DOMINATED=true for f in $(git diff --name-only HEAD~1..HEAD 2>/dev/null); do case "$f" in - *.md|*.sh|.github/*|.gitignore|.pkgmeta|DandersFrames/Core/Changelog.lua) ;; + *.md|*.sh|.github/*|.gitignore|.pkgmeta|DandersFrames/Core/Changelog.lua|DandersFrames_Options/Changelog.lua) ;; *) DOMINATED=false; break ;; esac done diff --git a/DandersFrames/Core.lua b/DandersFrames/Core.lua index 61382ca8..0fbca5e7 100644 --- a/DandersFrames/Core.lua +++ b/DandersFrames/Core.lua @@ -5516,6 +5516,8 @@ DF._MainEventDispatcher = function(self, event, arg1) elseif msg == "exportaudit" then -- Dev: verify every Config default is export-categorised or -- declared local-only (guards against export-list drift). + -- Category tables live in the companion. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then return end if DF.AuditExportCategories then DF:AuditExportCategories() end diff --git a/DandersFrames/Core/Changelog.lua b/DandersFrames/Core/Changelog.lua index 1914a23d..dd8341bc 100644 --- a/DandersFrames/Core/Changelog.lua +++ b/DandersFrames/Core/Changelog.lua @@ -1,1498 +1,7 @@ local addonName, DF = ... -DF.BUILD_DATE = "2026-07-31T13:36:23Z" +DF.BUILD_DATE = "2026-07-31T20:51:16Z" DF.RELEASE_CHANNEL = "alpha" -DF.CHANGELOG_TEXT = [===[ -# DandersFrames Changelog - -## [5.0.0] - -### WoW 12.1 (Midnight) Rework - -*The 12.1 rework is a big team effort — brought to you by Danders, Krathe and Maelareth. A single change below often includes work from more than one of us, so entries aren't credited individually.* - -DandersFrames has been rebuilt for WoW 12.1 (Midnight), which fundamentally changed how the game handles auras. This build runs on 12.1 only — 12.0.x stays on 4.7. Every aura row — buffs, debuffs, defensives and missing buffs — now renders through the game's new protected aura engine. - -* (Auras) **New: Dispel Text.** The debuff bar can show a short letter code on each debuff for its dispel type — Ma for Magic, Po for Poison, and so on — a colourblind-friendly cue alongside the coloured border. It uses the game's own wording for your language. Enable it in the new "Dispel Text" section on the Debuffs page, with font, size, colour and position options. No Accessibility setting is needed: the text shows whether or not Colorblind Mode is on. -* (Auras) New "Aura Duration Update Rate" setting (General > Settings) — choose how often aura countdown text refreshes: Smooth, Normal or Performance. Applies account-wide to the buff, debuff and defensive bars and Aura Designer indicators. -* (Auras) Permanent auras no longer show a duration timer on their icons. This is the new default for the buff, debuff and defensive bars and for Aura Designer indicators and groups — you can bring the timer back with the "Hide Duration on Permanent Auras" checkbox in each Duration section. -* (Auras) **New: Duration Bars.** Buff, debuff and defensive icons can now show a small bar that drains with each aura's remaining time, tucked just below (or above) the icon. Enable it in the new "Duration Bar" section on the Buffs, Debuffs and Defensive Icon pages, with height, gap, texture and colour options — and Aura Designer groups get the same section on each group card. -* (Auras) New sort controls: buff and debuff bars can show your own auras first and reverse the sort direction, and the defensive icon's order is now selectable (Externals First, Most Urgent, or Default). -* (Auras) **Duration text can now show a percentage.** The Duration Format dropdown gains a "Percent" option (shows "45%" instead of the seconds remaining), on the Buffs, Debuffs and Defensive Icon pages and on every Aura Designer indicator. The Aura Designer bar, which has room for a longer label, also gets a "Seconds + Percent" option that shows both at once (like "12s (45%)"). The old "Short" format is now labelled "Seconds", and "Full" (the wordy "14 Seconds" form) is no longer offered for icons where it never fit — it stays available for Aura Designer bars. Colour by Time still applies to the percentage, and "Hide Above" is unavailable while a percent format is selected (its threshold is in seconds). -* (Auras) **New "Order Applied" sort order.** Buffs and debuffs can now be sorted by the order they were applied, so an icon keeps its place for as long as the aura lasts instead of moving each time it refreshes — worth choosing if you track auras by position rather than by reading them. Available on the Buffs and Debuffs pages, and on every Aura Designer group card. "My Auras First" doesn't apply to it (it is one fixed order), so it greys out while it's selected. The Sort Order lists now read Default → Time Remaining → Alphabetical → Order Applied rather than alphabetically. -* (Tooltips) **Aura tooltip position and Disable in Combat work again.** The Tooltips page's Anchor, Offset and "Disable in Combat" controls for buff, debuff and defensive icons were greyed out on 12.1 as a game limitation — the new aura engine wouldn't let an addon place the tooltip. The current build restores that, so those settings drive the tooltip again: pin it to the icon with an offset, follow the cursor, or leave it on the game default, and hide it in combat. Changes apply live (at the end of combat if you're mid-fight). "Center" has no game equivalent and falls back to the default position. -* (Auras) The Dispellable debuff filter has a new "Any Dispel Type" mode — show every debuff with a dispel type, even ones nobody in the group can dispel. -* (Auras) New "Hide Permanent Auras" option for the buff bar — hides buffs with no duration, like auras that last until cancelled. -* (Aura Designer) Groups get sort controls too — each filter, other-buff and debuff group card now has its own Sort Order, "My Auras First" and "Reverse Order" options. -* (Auras) Changing aura filters, sort order, or max icons no longer rebuilds the icon rows — changes apply instantly in place, with no flicker. This covers the buff, debuff and defensive bars and the Aura Designer's filter and debuff groups. Changes made in combat apply the moment combat ends. -* (Aura Designer) **The Aura Designer now works for every spec** — previously healers and Augmentation only. The spec dropdown is grouped by class and searchable. -* (Aura Designer) **New: Filter Groups.** Place an entire filter on the frame as its own icon row — link built-in or custom filters, and every matching buff appears in a self-arranging row with its own position, icon size, growth and icon cap. Aura Filters changes apply to linked groups immediately, and buffs shown by a group leave the buff bar while "Hide Duplicate Buffs" is on. -* (Aura Designer) **New: Expiry Alerts.** Indicators can now warn you when a tracked buff is about to run out — below a threshold you choose, your own custom text or a warning glyph appears on the unit frame, positioned and sized however you like, so a tracked shield can literally say "Shield dropping!". Find it in each indicator's Expiry Warning section. -* (Aura Designer) Open dropdown menus now close when switching tabs instead of lingering over the new tab. -* (Auras) "Hide Long Debuffs" now also works while "All Debuffs" is enabled. "Keep important debuffs" still requires the category filters. -* (Aura Designer) Fix cooldown swipes on indicators and groups draining in the opposite direction from the buff and debuff bars. -* (Aura Designer) Fix error spam from the health bar overlay, most noticeable in follower dungeons and other content where players join and leave often. -* (Interface) Fix spells with hidden tooltips (like Strength of the Black Ox) showing an empty box in the Aura Filters spell list — they now show their name and spell ID. -* (Aura Designer) One spell picker everywhere — adding indicators, group spells and triggers all use the same spell database picker as Aura Filters, with search, class and category filters, and add-by-ID. -* (Aura Designer) Every indicator has a show/hide eye — turn one off without deleting it. -* (Aura Designer) The editor header is condensed to a single row; Sound Alerts moved to the Global tab. -* (Aura Designer) **Sound alerts do more than announce a new buff.** Each indicator's Sound section can now play a separate sound when a tracked buff **drops off** and when it **gains a stack**, alongside the existing on-applied alert (which now has its own on/off toggle). The sound system was also rebuilt on 12.1's new aura-sound engine so alerts keep working on the current build. The buff-dropped and stack-gained triggers need the current game build; the section notes this if your client doesn't have them yet. -* (Profiles) Custom filters and your category tweaks travel with profile exports — including filters linked in the Aura Designer. -* (Profiles) Custom filters picked inside a raid auto layout's settings are handled everywhere the rest of the profile is: deleting a filter now removes it from every layout (including the active one), exports carry filters only a layout references, and imports re-link them instead of pointing at the wrong filter on the receiving account. -* (Aura Filters) **New: share a single filter.** Filters can now be exported and imported one at a time, so you can send someone one filter instead of your whole profile. "+ Import Filter" sits under "+ New Buff Filter" in the list, and Export is in the button strip below it. Built-in presets can be shared too — they arrive as a custom filter holding whatever spells you had enabled. If you import a filter you already have, DandersFrames says so and lets you pick between keeping yours or adding a copy. -* (Frames) The Out of Range "Text Alpha" slider now also governs pet frame text and the test-mode preview — those still followed hidden per-element values no control could change. -* (Interface) In Aura Filters, a spell added by ID now shows its real name and icon (resolved from the game) instead of a bare "#id" and a question mark. It's tagged "not in database" so you can spot spells that aren't part of a built-in preset yet; only a genuinely invalid ID still reads "unknown ID". -* (Interface) Fixed the Aura Filters page's Add, Add from Database, Rename and Delete buttons filling with a solid colour on hover after switching between a built-in preset and a custom filter, and gave their disabled-state tooltips a consistent two-line layout. -* (Frames) DandersFrames no longer overwrites Blizzard's own raid-frame dispel indicator setting at every login — a leftover from a long-removed option that kept forcing a value you couldn't change. -* (Interface) The Aura Filters add-by-ID box now rejects over-long spell IDs like the spell picker does, and a spell picker first opened during combat regains its Escape-to-close afterwards. -* (Profiles) Role colours (tank/healer/damage) now travel with profile exports. -* (Aura Designer) The global Default Frame Level and "Draw above frame border" were marked as not yet available for part of the 12.1 alpha — they looked live but weren't applied. The z-order pass they were waiting on has since landed (below), so they work again. The Frame Strata dropdowns are gone rather than restored — see the same entry. -* (Interface) Removed the Resurrection icon's "Pending Text" box and the "My Buff Indicators" export category — neither had done anything (the text was never rendered; the category exported no settings). -* (Auras) Fixed an error ("Attempt to access forbidden object") from the dispel overlay when zoning out of an instance mid-run — the overlay's layout pass could measure a protected aura-button rect during the transition; it now measures safely and falls back to defaults for that one pass. -* (Bars) Fixed a gap on either side of a "Match Health Bar Width" resource bar at frame border sizes 1-3 (most visible with a non-zero Frame Padding). The bar now sits flush inside the border, and stays aligned with it at any UI scale. -* (Frames) Fixed grouped raid frames staying invisible after converting a raid to a party and back until a /reload — the frames were alive underneath, but their group anchors were never restored on re-entering the raid. -* (Aura Designer) Fixed the editor's preview icons vanishing after switching to another settings page and back — from the second round trip on they stayed missing until the window was reopened or an indicator was toggled. -* (Aura Designer) **Fixed new group names counting up forever.** Creating and deleting groups left the next one called "Group 7" on an empty designer, because the label came from a counter that never reset. New groups are now numbered from the groups that actually exist, so an empty designer starts at Group 1 again (a gap is still left where a group was deleted, so the list stays in order). Applies to Group, Filter Group and Debuff Group names; the internal ids are unchanged, so existing indicator links are unaffected. -* (Frames) **Fixed the Reduced Max Health overlay disappearing in Mythic+ and raids** — exactly where max-HP reductions happen. In restricted content the game returns the modifier as a secret value, and the overlay was hiding rather than touch it; the value can be fed straight to the bar (the same way health bars render at all on 12.1), so it renders again. Health-bar clipping stays off on that path as a safety measure. -* (Auras) **Fixed Pixel Perfect on the new aura engine.** With Pixel Perfect on, aura icon borders could render unevenly — a fat edge one side, a thin or missing edge another, a visible gap between border and icon, and adjacent icons in a row each looking different. Borders now snap to whole screen pixels in their actual rendered space (including scaled icons), icon sizes and spacing quantize to whole pixels so rows stay uniform, and each icon row is nudged onto the pixel grid — restoring what Pixel Perfect delivered before the 12.1 rework, across the buff/debuff/defensive bars, Aura Designer indicators and groups, and missing-buff icons. -* (Profiles) Fixed a doubled "v" (like "vv5.0.0") in the version shown after parsing a profile import string. -* (Auras) Fixed an error storm ("Attempt to access forbidden object") from the dispel overlay's out-of-range fade during combat in restricted content — the fade wrote alpha directly on the game's protected aura buttons; it now dims the overlay through its own wrapper, same result without touching protected objects. -* (Auras) **Fixed random buffs (food, flasks, everything) flooding spell-ID-filtered bars for cross-faction group members.** The game only honours spell-ID aura filters for units you can currently assist — and a cross-faction group member only counts as assistable inside instanced content (dueling a party member does the same). Outside, the filter silently stopped applying and every filtered surface could show wrong buffs on that player — the buff and defensive bars, missing-buff icons, and Aura Designer indicators (including "My Buffs" ones, where a buff you cast in the instance could keep showing in the wrong indicator afterwards). All of these now hide for that unit until they're assistable again, re-checking after loading screens and as member data settles — no more garbage rows for cross-faction friends in the open world. -* (Auras) **Fixed "Only My Buffs" showing other players' buffs on a party member who's in a different instance than you.** The "my buffs" filter needs each aura's caster, which the game can't attribute for a unit outside your instance — so it quietly fell back to showing that buff from *everyone* (the spell-category filters kept working, so it looked like only that one option had broken, and `UnitCanAssist` stayed true so the cross-faction fix above didn't catch it). Those pools now hide for a unit who isn't in your visible world, the same way cross-faction pools do. Same-instance members far out of range are unaffected. -* (Auras) Renamed two built-in filter presets: "Per-spec Healing" is now **Healing**, and "Raid Defensives" is now **Raid Cooldowns**. Your selections and per-spell toggles carry over unchanged. -* (Frames) Clicks on party and raid frames no longer redirect to a member's vehicle or pet slot while the game flags them as being in a vehicle (sitting on housing furniture counts) — a legacy behaviour Blizzard's own frames dropped, which could target the wrong unit or open a pet menu. -* (Auras) **Custom dispel colours are back.** Set your own colour per dispel type (Magic, Curse, Disease, Poison, Bleed/Enrage) in a new **Dispel Colors** section on the Colors page — one shared account-wide set. Both the debuff-icon border (via "Color by Dispel Type") and the Dispel Overlay follow it directly, with no separate mode to switch on. Defaults are the game's own dispel palette, so nothing changes until you edit it, and Reset restores the game colours. Each feature links to the Colors page. -* (Auras) Dispel colours now apply the moment you pick them — changing one no longer rebuilds the aura icons, so the brief flicker on every colour tweak is gone. -* (Auras) Fixed test mode showing the game's dispel colours instead of your own — the preview was overriding your Dispel Colors, so custom colours looked like they only worked on live frames. -* (Auras) **New: Optional Debuffs.** Hide nuisance debuffs from the debuff bar, on the Aura Filters page under **Debuffs → Optional Debuffs**. Everything in the list shows by default; unselect one to hide it. The Sated/Exhaustion (post-Bloodlust) family is hidden by default, with BG/Dungeon Deserter, Ride Along and the Mythic+ **Challenger's Burden** available as opt-in hides. Buffs stay opt-in via the buff presets and custom filters, so this list is debuffs only; and only debuffs the game leaves non-secret can be hidden (a 12.1 limitation — encounter debuffs stay visible). -* (Auras) **New: Debuff Filters.** The debuff bar is now filtered by categories on the Aura Filters page — Boss, Role, Priority, Crowd Control, Raid and Dispellable (by you or by anyone). "Show All Debuffs" keeps the old behaviour. -* (Auras) **The debuff bar now defaults to "All Debuffs".** Blizzard's category filters aren't exhaustive — some debuffs are tagged with none of the categories and slip through even with every one enabled, so "All Debuffs" is the only complete option. Category mode is still available; a caution note under it now explains the gap (the categories are Blizzard-defined and can't be widened) — the old "use the categories instead" nudge is gone. Existing profiles keep their current setting; Reset Page picks up the new default. -* (Auras) The "All Dispellable" debuff filter mode now actually filters — it previously showed every debuff. -* (Auras) New "Hide Long Debuffs" filter: hides debuffs over a duration threshold, with an option to keep boss, role and priority debuffs visible. -* (Auras) Buff and debuff filter preferences have been reset to new defaults as part of this rework. -* (Profiles) A settings page's Copy, Sync and Reset buttons no longer touch settings owned by a different page. -* (Auras) **New: Buff Filters.** The buff and defensive bars are now filtered by curated spell categories — healing, raid buffs, defensives, movement and more. Pick the buff bar's categories on the Aura Filters page and the defensive bar's on the Defensive Icon page. -* (Interface) **Aura Filters now shows what's inside a filter.** The page lists every filter on the left with its own checkbox, and selecting one shows its full spell list on the right — so choosing which filters a bar uses and editing what those filters contain happen in one place, instead of the filters being an opaque name you switch on and hope about. Every spell in a built-in filter can be unselected, and you can build your own filters, adding spells by ID or from the spell database. Buffs and Debuffs are tabs. -* (Auras) Filter presets start with passive "noise" auras unselected — mastery procs and auto-applied side effects. Everything stays listed on the Aura Filters page, one click to bring back. -* (Auras) Spell database updated with the latest harvest — two new filter categories, Tier Set Auras and Racials, plus dozens of new spells and placement fixes. -* (Tooltips) Aura tooltip Enable toggles now apply immediately. Anchor/Offset and Disable in Combat can't apply to 12.1 aura icons (game limitation) and are marked in place — they still work for Frame and Binding tooltips. -* (Aura Designer) The editor's preview canvas renders through the real 12.1 engine — what you see while styling is exactly what ships to the frames, including out-of-range fading. -* (Auras) **New: editable duration colours.** "Color by Time Remaining" now uses colour breakpoints you set yourself, in a new **Color by Time** section on the Colors page — each stop is one row with its colour, the range it covers and the point it takes over, and you can add or remove stops freely. Shared across buffs, debuffs, defensives and the Aura Designer, and defaulting to a bright green → gold → orange → red ladder. -* (Auras) **Colour by seconds remaining, or by percent of the aura's duration.** The Color by Time editor has a tab for each — they're separate colour ladders, so "under 3s" and "under 25%" can look different — and the defaults now line up, both splitting into even quarters. A percent ladder suits mixed durations, where a fixed number of seconds means very different things on a 6-second shield and a 5-minute buff. -* (Auras) **Duration text can blend smoothly between the stops.** A "Blend Colors Smoothly" toggle makes the countdown fade through the ladder instead of snapping band to band. This applies to duration text only, account-wide; the expiry Border and Tint reveals always step, which the editor now shows you directly — a "How this renders" legend previews each one exactly as it will appear, with that consumer's own controls on its row. -* (Aura Designer) **Each expiry alert picks its own seconds-or-percent.** The threshold beside every indicator's Expiry Warning has an s / % toggle on the value box, so one indicator can warn "below 5s" while another warns "below 30%", independent of what the duration text uses. -* (Auras) Every "Color by Time Remaining" setting — on the buff, debuff and defensive pages, and throughout the Aura Designer (including the new expiry Border/Tint reveals) — now carries a "Colors page" link that jumps straight to that shared breakpoint editor, expands it if collapsed, and highlights it. -* (Aura Designer) Test mode previews the Aura Designer through the real engine — every placed indicator renders with its configured spell, styling and animations, exactly as live. -* (Interface) **Border animations are now entirely DF's own.** Four new effects — DF Chase, DF Flash, DF Pixel and DF Proc — join DF Pulsate, DF Dash and Blink; the old library-backed effects are retired. -* (Auras) **Animated borders are available on the Missing-Buff icons**, with the full DF animation set. The animation driver is now shared, so animated borders are lighter across the board. -* (Auras) Border animations aren't offered on the aura icons themselves — buff, debuff, defensive and Aura Designer indicators. On 12.1 the game hides aura data in combat and forbids addons from touching aura icons while it's hidden, so an animated border there would break; those borders are static. Frame borders, the Missing-Buff badge and targeted-spell highlights are unaffected and still animate. -* (Auras) Out-of-range and dead-unit fading works on the 12.1 aura displays again — live and in test mode. -* (Auras) Aura text is sharper and countdown timers stay put — text now renders at its true size instead of being scaled up (which softened it, more so at larger sizes), and centred timers no longer drift or wobble as they tick down or when a text outline or shadow is enabled. -* (Aura Designer) Icon and square indicators have refreshed defaults — **DF Roboto SemiBold** with a drop shadow, slightly larger duration text and the stack count tucked into the bottom-right corner (was Friz Quadrata, no shadow, centred stacks) — matching the buff and debuff bars. Indicators still on the untouched old defaults are updated automatically; anything you've already customised is left alone. Newly placed indicators start with duration text coloured by time remaining (as they always have), and new or reset profiles have it on out of the box — existing profiles and already-placed indicators keep their current setting. -* (Aura Designer) The spell picker shows one unified list — the separate "Inferred Tracking" section is gone; every tracked aura now works the same way. -* (Aura Designer) Filter and debuff group boxes in the editor preview show greyed example icons, sized and arranged to your layout settings. -* (Aura Designer) Filter and debuff groups can be styled like placed indicators — Appearance, Border, Duration Text and Stack Count sections — and the preview's example icons wear your styling live. -* (Interface) The deprecated My Buff Indicators feature has been fully removed — the missing-buff display and the Aura Designer cover its use cases. -* (Internal) **The pre-12.1 aura pipeline is fully removed** — every aura surface now runs exclusively on the 12.1 engine. If any aura display behaves differently than before, please report it. -* (Dispel) Fixed the Top/Bottom/Left/Right Edge gradient styles rendering as health-shaped fragments after "On Current Health Only" had been active. -* (Auras) Fixed a doubled gap between filter blocks when more than one filter is enabled — rows are uniformly spaced. -* (Auras) **Centered growth works again** — rows set to grow from the center stay centered as icons appear and disappear, in combat too. The dropdown is now labelled **Grow**, with a **From Center** option. -* (Test Mode) **Test mode previews buffs, debuffs, defensives and missing buffs through the real 12.1 engine** — actual container rows with live-true layout, borders, fonts, animations and tooltips. While test mode is open, the game's own aura displays temporarily show sample data; everything restores when it closes. -* (Auras) The debuff "Color by Dispel Type" ring is now a flat square line matching your border's style and thickness, instead of the game's rounded ring art. -* (Borders) Fixed switching a border from Gradient back to Solid sometimes leaving the gradient stuck until a /reload on aura icon borders. -* (Profiles) Fixed profile resets not sticking for the Aura Filters — filters no longer flip back to an old preset after the next /reload. -* (Profiles) Fixed "Reset Profile to Defaults" leaving the Aura Designer and Text Designer untouched — a reset now rebuilds both designer libraries to their defaults. -* (Text Designer) Fixed the Text Designer going blank until a /reload after a profile reset. The default health text stays current/max. -* (Text Designer) The Texts and Text Groups tabs now show a live count of their enabled elements. -* (Interface) The settings window now defaults to **DF Roboto SemiBold**. Profiles still on the old default switch once — pick Friz Quadrata again to keep it. In-game frame text is unchanged. -* (Integrations) Removed the Click-Through Icons options — 12.1 aura icons are always click-through, so the toggles no longer did anything. -* (Class Power) Removed the Class Power Pips display and its settings page, along with its Test Mode toggle. -* (Performance) Disabling the Blizzard party/raid frames now fully shuts them down instead of just hiding them, stopping their background work. Toggling this (or Show Side Menu) now prompts for a UI reload. -* (Dispel) **The Dispel Overlay is now one unified system driven by the game's aura engine — and it covers boss debuffs (private auras) natively.** The old source selector is a single Enable toggle (existing settings migrate), colours come from the game's dispel palette, and everything else — border, glow, gradient, blend, darken, icons, pulse — stays yours to style, live and in combat. Also fixed a Lua error when opening Edit Mode with the overlay active. -* (Auras) Opening WoW's Edit Mode no longer floods the aura rows with random sample icons — rows hide during Edit Mode and restore when it closes. -* (Profiles) Refreshed the default profile's look — new installs and profile resets only. Tighter frames, buff borders on, Missing Buffs on by default, a cleaner pet style, and the Big/External Defensive filters no longer default on. -* (Profiles) Fixed several border settings (Hide Intro Flash, border Colour Source) silently reverting on profile export/import and party↔raid copies. -* (Test Mode) **Rebuilt the Quick Presets** (Static / Combat / Healer / Full). Each preset now sets every toggle, so they're reproducible — "Full" is genuinely everything — and applying one repaints the preview immediately. -* (Test Mode) Fixed the party-only Targeted List and Personal Targeted settings leaking into the raid preview. -* (Test Mode) The Targeted Spells and Targeted List toggles are greyed out in raid test mode — both are party-only features. -* (Boss Debuffs) **Boss debuffs are now part of the regular debuff row** — including private auras, with duration, stacks and dispel colouring. The separate Boss Debuffs display and its settings page are retired. -* (Missing Buffs) **Missing Buffs now work on 12.1 — including in combat and Mythic+.** The feature is display-only and driven by the game engine, so no combat delay and no Mythic+ blackout. In manual multi-buff mode every missing buff shows its own icon, and missing buffs now show for NPC party members too. -* (Defensives) Fixed a defensive appearing twice when it counts as both personal and external (e.g. Guardian Spirit) — externals someone cast on you sort first. Also removed a duplicated pair of Duration Offset sliders. -* (Auras) Debuff settings caught up with the buff row: Duration Format, Duration and Stack colours, filters and Sort Order all drive the new rows; settings the rows can't provide are marked in place. -* (Click-Casting) Fixed right-click target/menu erroring with click-casting enabled — a 12.1 game bug; the workaround retires automatically once the game fixes it. -* (Auras) **Debuffs now render through the 12.1 aura engine** — the last row on the legacy path, which could show nothing in combat. Layout, borders, text and sort work like the buff row, and a new "Dispel Border Inset" slider positions the dispel ring. -* (Auras) Buff "Sort Order" works again — Time Remaining and Alphabetical apply natively, in combat too. -* (Auras) New "Hide Long Buffs" filter: hides buffs whose total duration exceeds a threshold — e.g. hour-long food buffs. Permanent buffs are also hidden while this is on. -* (Auras) **Masque skinning support has been retired** — the new 12.1 aura icons can't be skinned by third-party addons, so the Masque option on the Integrations page no longer did anything and has been removed. DF's own border settings always apply. -* (Auras) Texture-style aura borders no longer scatter, lose their colour or vanish until a /reload — and fine border detail stays crisp at small icon sizes. -* (Auras) "Color by Time Remaining" works again on duration text, ticking in combat — fixed time steps (red under 5s, orange under 15s, yellow under a minute). -* (Auras) Buff and defensive icon text share one styling system, adding Duration and Stack colour options. The "Short" format no longer shows a stray space. -* (Auras) Fixed Icons Per Row wrapping one icon early. -* (Auras) Aura rows honor your layout settings again — anchor, direction, wrap, offsets, spacing, size and scale were previously ignored. -* (Auras) Aura settings apply in real time again — sliders re-flow the row live during the drag. -* (Interface) Settings the 12.1 rows can't provide are marked in place: "Min Stacks to Show", "Icons Per Row" on vertical rows, and "Border Animation" on buff icons. -* (Auras) Fixed an in-combat error that permanently froze a frame's aura updates until reload. Stack counts now use the built-in display (shown at 2 or more). -* (Auras) New buff duration format option (Number / Short / Full). -* (Aura Designer) **The Aura Designer is fully rebuilt on the 12.1 aura system.** Every indicator type works again — and keeps working in combat — including animated borders, sound alerts and "Show When Missing". The few effects 12.1 can't support stay marked "Blizzard limitation" in the panel. -* (Aura Designer) Fixed layout groups stacking all their spells on one spot — grouped indicators arrange into their grid again, and a missing buff leaves its slot empty. -* (Aura Designer) Adding spells to a layout group now uses the full searchable spell picker — click Icon or Square to drop a spell in, and the picker stays open for adding several. -* (Aura Designer) **New: Other Buffs tab.** Track any buff on your frames no matter who cast it — for example, Power Infusion on anyone in your group — with the same indicators, effects and sound alerts. Your own class's spells now live under the "My Buffs" tab. -* (Aura Designer) New "Others Only" option on Other Buffs effects and filter groups: only show the effect when someone else cast the buff — your own casts still show on the regular buff bar. -* (Aura Designer) A spell can be tracked in one tab at a time — the picker shows where a spell already lives, so the same buff never renders twice. -* (Aura Designer) Layout groups and filter groups are available on the Other Buffs tab too, shared across all your specs. -* (Aura Designer) **New: Debuffs tab.** Give debuff categories their own icon rows on the frame — for example, crowd control in its own spot with its own icon size. Tick a group's categories, set its layout, and those categories leave the main debuff bar automatically. Each group has its own dispellable mode and "Hide Long Debuffs" option. - -* (Aura Designer) My Buffs now reacts only to your own buffs — another player's cast of the same spell no longer lights your indicators, effects, or groups. Use the Other Buffs tab to track a buff from anyone. -* (Auras) Each Aura Filters tab opens with a line saying what you can do there: buff filters are wholly yours to edit or replace, while the debuff categories are Blizzard's and fixed. The old Aura Filter Setup wizard has been removed; use the Reset Page button to return filters to their defaults. -* (Interface) Overrides now read the same way across the whole addon: auto layouts and Aura Filters use one coloured dot — with a hover tooltip saying what's overridden — in place of the old mix of stars and boxes, and every reset-to-default control shares one red icon style. -* (Auras) Tuned the default buff-filter presets: some spells now default on or off (for example Infusion of Light and Immolation Aura start off), a few non-aura entries such as totems and guardians were dropped, and several spells moved to more fitting categories. Your own per-preset enable/disable choices are not affected. -* (Auras) **Duration bars can colour by remaining time.** A new Color Mode on the Buffs, Debuffs and Defensive Icon duration-bar sections (and on Aura Designer group cards) adds three colour curves alongside the existing static colour: **DF Smooth**, which blends through the addon's expiry palette, **DF Stops**, which shows that same palette as four hard bands so the bar reads like stepped duration text, and **Classic**, a traditional green-to-red. The bar's colour walks the curve as the aura drains. Note this tracks the *proportion* of the aura remaining rather than a number of seconds, and the curve colours are fixed to the default palette — the duration *text* is what follows your own Color by Time breakpoints. Texture and Bar Color grey out while a curve is selected, since the curve supplies both. The default duration-bar gap is also now 1 (was 2). -* (Auras) **Fixed duration bars filling up instead of draining.** The bars were inheriting the game's default counting direction, which measures *elapsed* time — so a "duration bar" started empty and filled as the aura ran out, the opposite of what the setting describes. They now count remaining time and drain, as intended. Reverse Fill still mirrors which edge the bar drains toward. -* (Aura Designer) **Placed icon and square indicators now have a Duration Bar too.** Previously the duration-bar strip was only offered on Layout Groups; each individually placed icon/square now has its own Duration Bar section with the same options — enable, position, height, gap, Color Mode (Static / DF Smooth / DF Stops / Classic), texture, colours, and reverse fill. -* (Aura Designer) **Bar indicators gain a Color Mode.** The standalone Bar indicator now has the same **Color Mode** (Static / DF Smooth / DF Stops / Classic) as the other duration bars, in its Texture & Colors section. Pick a curve and the bar's fill reddens as the aura drains (Bar Texture and Fill Color grey out then, since the curve supplies both). This is a bar's proper "expiry colour" — the Expiration section on a bar handles only a Text/Glyph alert. -* (Aura Designer) **New Expiry Alert "Border" and "Tint" modes.** Alongside Custom Text and Glyph, a placed icon/square's Expiry Warning can now show a coloured **frame** (Border — in three thicknesses) or a **solid wash over the whole icon** (Tint) when the aura is about to expire. Either matches the icon size automatically (with an Inset to nudge it), takes an Opacity, and its colour can be a fixed pick **or** follow the same **Color by Time** breakpoints as your duration text — so it reddens as the aura runs down, in step with the numbers. On **bar** indicators the reveal offers a Text or Glyph alert only — a bar's *colour* expiry is its own fill via the Duration Bar's Color Mode (a DF Smooth / DF Stops / Classic curve reddens it as it drains), so a Border/Tint overlay isn't offered there. The Expiration panel has an Enable toggle and a Type dropdown, and shows only the controls that apply to the type you pick. Fully combat-safe (driven render-side, no reliance on reading the remaining time). - -* (Interface) **The settings window has been rebuilt on one shared foundation.** Every surface — the options pages, the Aura and Text Designers, the spell and icon pickers, the frame movers, Pinned Frames, Search and Test Mode — used to build its own boxes, hover states and row geometry, so they drifted apart over time and a change to the look had to be made in a dozen places. They all come from one factory now, so a box, a button or a row looks and behaves the same wherever you meet it. -* (Interface) **Fixed borders in the settings window thinning, looking uneven, or vanishing outright.** A one-pixel line can only be drawn cleanly when it lands exactly on your monitor's pixel grid; at most UI scales it lands between two rows and splits its ink across both, and at the faint greys this interface uses, both halves can fall below what a screen will show. It was worst while scrolling, where the line crosses the grid every frame. Borders are now drawn as textures the addon owns, thick enough not to care where they land, so they look the same at any scale and stay solid in motion. -* (Interface) **Consistent spacing throughout the settings window.** Section headers used to sit almost on top of the control below them, and the gap between two controls depended on which two they were — the same nominal gap varied by a factor of four across different pages. Spacing now comes from one set of numbers, measured rather than guessed. A run of checkboxes closes up so it reads as one list, while the gap to a different kind of control below stays full. -* (Interface) **Every settings page now follows one layout rule.** The left column holds structure and geometry (Settings → Layout → Size → Position); the right column holds styling (Appearance → Border → element extras). Pages that genuinely need the full width — Pinned Frames, Nicknames — still get it, and pages with no geometry/styling distinction at all (Tooltips, Settings, Sorting, Manage, Import/Export) balance instead. Applied across Pet Frames, Resource Bar, Missing Buffs, Defensive Icon, Group Labels, Frame, Dispel Overlay and Aura Filters. Three boxes that had crept to full width on ordinary two-column pages (Pet Frames' top pair, Fading's Health Threshold, Pinned Frames' Auto-Populate) are back to column width — a full-width box also drags both columns down to align, so they were skewing the pages they sat on. -* (Interface) **Fewer things to expand before you reach a setting.** Collapsible headers now mean one thing — "here is another one of these", like each icon on the Icons page or each highlight on Highlights. Headers that merely named a category (Layout, Appearance, Settings) are gone from the Buffs, Debuffs, Personal Targeted and Targeted List pages. On the **Icons** page this went a level deeper: each icon's Settings, Appearance and Position boxes were themselves collapsible on top of the icon section that already collapsed, so opening "Leader Icon" gave you three more things to open. Those boxes are now always open, and the section headers match the width of the boxes beneath them. -* (Interface) **Every confirmation and name prompt is now DandersFrames' own dialog.** All 26 of them used Blizzard's popup, which can't be themed to match the rest of the addon, can't hold a text field, and treats Escape as "hide this box" rather than "cancel what it was asking about". Renaming a profile, deleting a filter, importing a string and everything like them now go through one themed dialog that cancels properly. -* (Interface) **Tooltips work the same way everywhere.** They used to attach three different ways, appear from three different places, and follow three different anchors depending on which control you hovered. There is now one system: hover a setting's **label** to see its tooltip, and it appears just above the cursor rather than on top of the control you were about to drag. More settings have them too, though deliberately not all — a slider labelled "Max Buffs" explains itself, while "Inset" reads as jargon and now says what it does everywhere it appears. -* (Interface) **Settings that don't apply now grey out instead of disappearing.** Turning a feature off on the Targeted List, Personal Targeted, Buffs, Debuffs, Missing Buffs and Defensive Icon pages used to remove its controls from the page entirely, so the page reflowed and you couldn't see what you'd be turning back on. They stay put and grey, which is what the rest of the addon already did. Targeted List's border settings also move into their own Border box — they were rendering inside the bar-style group. -* (Auto Layouts) The page now shows a clear disabled state when the feature is off, matching the Aura and Text Designers. It was previously possible to add and edit layouts while Auto Layouts was switched off, with no indication they wouldn't be used. -* (Interface) **Targeted Spells has been removed.** The group/on-frame half of the feature depends on aura information 12.1 no longer exposes to addons, so it is gone along with the setup wizard that configured it. **Personal** targeted spells and the **Targeted List** are unaffected and stay where they are. -* (Interface) **"Preset" and "Template" now mean different things.** A saved Aura Designer or Text Designer setup is a **Template**; "preset" is reserved for the built-in aura filters and the export/test quick-picks. Your saved data is untouched — the underlying keys deliberately still say preset, because they travel in export strings and renaming them would break every string already shared. -* (Aura Designer / Text Designer) **Copy & Sync is gone from both designers; sharing is shown on the template bar instead.** These two pages differ from every other mode-specific page: they own a single setting — which template you're using — and the dropdown directly above the buttons already sets it. Two controls writing the same value is what made "Stop Syncing Only" appear to do nothing. The template bar now marks a shared template, names who else is using it (Party, Raid, Auto Layouts, Pinned Frames) and says that edits apply there too. To split a shared template off for one mode, use **Duplicate** — which also lets you name the copy. -* (Interface) **Aura Filters: clearer names and readable rows.** "Other Buffs" is now **Any Buff**, the buff sections are named for what they hold, and New Filter sits in the section it creates into. Every list uses the same checkbox, and a selected box means one thing throughout: this shows on your frames. Spell rows lost their per-row "i" button — the spell IDs it showed now ride along with the tooltip you already get from hovering the icon or name, so spells added by ID that the database doesn't recognise finally have a tooltip at all. Remove buttons on a hovered row no longer dissolve into the highlight, and both panels use the window's full height. -* (Interface) **A filter now tells you where it's used.** Under the selected filter's name: a state dot and the places it applies — "On · Buff bar (Party), Defensive Icon", or "Off · not in use". Editing a filter changes it everywhere, so this answers, before you touch anything, what else you're about to affect. It reads the live setup rather than a fixed list, including Aura Designer **filter groups** that link the filter, and it follows use *anywhere* — so a filter the Defensive Icon uses reads green even while it's unselected for the buff bar, which is correct and worth knowing. Hovering it explains the three scopes that make filters look broken after a Party/Raid switch: which filters are selected is per mode, a built-in filter's spells are per profile, and a custom filter's spells are shared by every profile on the account. -* (Interface) The debuff-category caution ("only All Debuffs shows every debuff") moved out of the page banner and into the filter list, directly under the All Debuffs switch, and only appears while that switch is off — next to the control it's about, and no longer displacing the page's own explanation. -* (Auras) **Buff and debuff ordering and limits moved to the Buffs and Debuffs pages.** Sort order, "My Auras First", reverse order, the maximum-duration cap and "Hide Permanent Auras" now sit in an **Order & Limits** box on the page whose bar they affect, alongside the rest of that bar's behaviour, rather than on the filter page. The Dispellable **Mode** dropdown moved with them. -* (Interface) Aura Filters gains the addon's standard **Copy / Sync / Reset** row, so a set of filters built for Party can be shared with Raid — which matters because filter *selection* is per mode, even though what a filter contains is not. -* (Interface) The Aura Filters search box now carries the same magnifier icon as the addon's main search, and the **Add from Database** button sits beside the Spell ID box, so both ways of adding a spell are on one row. The filter list is wider as a result. -* (Interface) The **Icons** page no longer nests collapsible sections inside collapsible sections. Each icon's settings open and close as one, instead of expanding to reveal three or four more things to expand, and the page is the same width as every other single-column page. -* (Interface) **The Targeted List page's box names say what they are** — "Show" is now "Show Text", "Font" is "Text Font", and four boxes that just said "Position" now name what they position. -* (Interface) The Tooltips page's boxes are paired by what you hover — Frame with Binding, Buff with Debuff, Defensive with Resurrection — so the two you'd want to keep in step sit side by side. A stray alignment marker that opened a ~70px hole in the left column is gone. -* (Interface) Fixed the settings menu's hover highlight blinking off between rows — a two-unit dead band between each pair of rows meant the highlight had nothing to sit on as the cursor crossed it. Also fixed the whole menu list sitting a fraction of a pixel off the display grid, which softened all 42 rows, and the scroll area clipping its contents against a fractional edge, which cut into any box near the top of a page. -* (Interface) Fixed drag-to-reorder lists (auto layouts, indicator order) accumulating enough positioning error down the list that adjacent row borders merged into a single line. -* (Interface) Sliders now fill their row like dropdowns instead of stopping short of it, and override lists show setting names rather than raw internal keys. The "See Also" links park at the bottom of a short page, where they read as the footer they are. -* (Frames) **Frame Level settings apply immediately again.** Changing a Frame Level did nothing until a `/reload`, because a rebuild never re-applied it. The sliders also show the level that's actually in use, and rows no longer overlap each other. The per-element **Frame Strata** dropdowns have been removed — z-order now runs through a single path rather than three copies that could disagree, and strata isn't something you need to set per element for that to work. The guidance that used to sit as 21 blocks of body text across the settings pages is now a tooltip on the control itself. -* (Interface) The "not yet available on 12.1" frosting is gone from the settings pages. Every control it covered is now either working or removed — nothing is left in the interface marked as blocked. -* (Test Mode) **Fixed frames staying stuck on test data until a `/reload`.** If test mode ended because you entered combat or changed zone — rather than because you switched it off — the fake aura data was never handed back, so your frames kept showing it. -* (Profiles) **Fixed a brand-new profile being run through the one-time upgrade steps meant for old ones**, which have nothing to upgrade and the wrong shape to upgrade from. Fixed one of those steps deleting Aura Designer configurations outright while re-scoping them per spec. -* (Auras) Fixed a dispel pulse continuing on two of its three groups after it should have stopped, a failed aura-border attachment giving up instead of retrying, and a stale cache of frame attributes surviving a rebuild. -* (Frames) **Fixed dead and offline units not updating their status text or their missing-buff badge.** A unit that died kept whatever its frame was showing a moment earlier — no "Dead" or "Offline" status, and a missing-buff badge still asking you to buff someone who could no longer receive it. Both corrected only when something else forced a full refresh, often when you died as well, which is what made it look like your own state mattered. Neither check was wrong; neither was being re-run. Most visible in follower dungeons, where companions go down often. -* (Profiles) Fixed Copy, Sync and Reset Page quietly leaving some settings behind on seven pages, with nothing to say they'd been skipped. Fading was the reported case. -* (Internal) Code cleanup: removed unreferenced functions, orphaned locale strings and permanently-disabled blocks. No behaviour change. -* (Auras) **New: Important Debuffs.** Boss, role and priority debuffs can now stand out in the normal debuff bar without moving them somewhere else — make them larger and mark them with a warning badge, so the debuff that matters reads at a glance among the rest. The new **Important Debuffs** section on the Debuffs page has the size step, badge size, corner and offset, and colours for both the badge and its mark. Off by default. -* (Auras) **Targeted List no longer loses the opening cast of a pull.** "Hide Out of Combat" was checking whether the caster was in combat before checking who the cast was aimed at — and a mob whose opening move is a cast isn't flagged in combat yet, so the cast you most want to see was dropped. The setting defaults on, so this happened out of the box (measured at 10 lost casts in a single 19-minute session). It still hides idle NPC chatter, but no longer costs you openers aimed at your group. -* (Auras) **Targeted List now picks up casts that were already in progress.** Walk into range, turn the camera, or have a mob join a pull mid-cast and that cast produced no bar at all — it only appeared on the mob's *next* one. Personal Targeted Spells always handled this; the list now does too. -* (Auras) **New: "Show Offscreen Nameplates" on the Targeted List and Personal Targeted Spells pages.** Both features can only see a cast if the caster has a nameplate, so with WoW's offscreen-nameplate setting off, an enemy behind you produces no cast bar — confirmed in game with a targeted, visibly-casting mob that wasn't picked up until you turned to face it. The checkbox changes that Blizzard setting directly, so it affects all nameplates and not just cast detection; the tooltip says so plainly. -* (Aura Designer) **Frame Level sliders now show their real value.** The Aura Designer reported a lower level than it actually rendered at, so layering it against the defensive icon or the aura bars meant guessing — the defensive icon had to be pushed to 85 to sit on top of something that read 30. Every level is now the absolute value it says, and the Aura Designer's default returns to 40 (existing profiles are migrated). `/df debug zorder` also covers the Aura Designer now, printing the level you set beside the level it renders at. -* (Aura Designer) Fixed two square-indicator bugs: "Hide Cooldown Swipe" still left a yellow sweep animating over the square, and "Hide Icon" turned a square back into an icon. -* (Aura Designer) **Fixed Expiry Alert glyphs drawing underneath the icon they belong to** — on the frames, in test mode and in the editor's preview canvas. Also fixed an expiry alert appearing on its own, with no indicator beneath it, when one was configured on an indicator set to "show when missing" (two settings that contradict each other, so the alert is no longer built there at all). -* (Test Mode) **The preview now updates as you drag.** Buff, debuff, defensive and missing-buff previews only refreshed when you released a slider — and some changes needed test mode toggled off and on before they showed at all. Every container surface now follows changes live while you drag, the way the Aura Designer's preview already did. -* (Interface) **Border thickness now means what it says on textured borders.** DF Glow, DF Bevel, DF Inset and DF Double only ever drew about a quarter of the thickness you asked for: the line inside each texture filled a fraction of the space allotted to it, so setting thickness 8 gave you roughly 2 pixels where a solid border gave 8. All four have been redrawn to fill properly, so a textured border now carries the same weight as a solid one at the same setting, and the corners are drawn to match the edges they join. Note: your existing borders will look noticeably thicker at the same number — lower the thickness to taste. -* (Interface) **The Debug Console is now one system.** Every trace in the addon routes into a single console with named categories, so capturing a problem means turning on the relevant category and reproducing it — rather than hunting for whichever per-feature flag happened to cover that code. Logs persist across `/reload` and can be exported as text, which is the thing to attach to a bug report. Noisy categories stay off unless you ask for them, and every category is listed before it has logged anything, so you can pre-disable the chatty ones and stop the trace you actually want being pushed out by the line cap. The old per-feature debug flags are gone — six of them had already stopped doing anything. -* (Interface) **One debug command form: `/df debug `.** The debug commands are down from 36 to 25, with three overlapping pairs merged into single commands (auras, dispel, headers) and developer-only commands no longer listed on normal builds. Note: the standalone one-word `/dfsomething` commands are no longer registered. They put around 25 DandersFrames commands into the global slash namespace, where they collide with other addons and clutter every slash autocomplete, to document a spelling nobody needed twice. A diagnostic is now typed in full — `/dfpixelcheck` becomes `/df debug pixelcheck`, and likewise for the rest — so any macro using a one-word form or the short `/df pixelcheck` spelling needs updating. Everyday commands (`/df test`, `/df lock` and so on) are unchanged. `/rl` is kept, since it has no `/df` form to fall back on. - -### Bug Fixes - -* (Click Casting) **Fixed hover keybinds never working on frames that appear after you open the settings window** — opening the Click Casting options replaced the internal frame that owns hover bindings with a piece of the settings panel, so every frame created from then on (arena and boss frames, nameplates, frames for people joining your group) silently never got its keyboard bindings, and the automatic repair could not fix it either. A reload was the only way back. (by Krathe) -* (Click Casting) Fixed keyboard binds going dead on every frame for a whole fight when combat interrupted a binding refresh — typically zoning into a raid and a pull starting seconds later. (by Krathe) -* (Click Casting) Fixed hover keybinds dying for the rest of the session on some frames after visiting player housing. (by Krathe) -* (Click Casting) Fixed cast-on-down silently switching itself off on Blizzard's own raid and party frames after every roster change. (by Krathe) -* (Pet Frames) Fixed pet frames never appearing in arena (2v2, 3v3 and Solo Shuffle). Arena counts as a raid to the game, so pet frames were being looked for on the raid frames — which arena does not use. They now build alongside the arena frames, and follow your Party pet settings. (by Krathe) - -### Improvements - -* (Click Casting) Work that has to wait for combat to end — profile switches, frame registration, binding refreshes and keyboard-bind rebuilds — now runs from a single queue that always drains, instead of ten separate flags; a keyboard-bind refresh asked for during combat was previously dropped entirely. (by Krathe) -* (Click Casting) Binding-settle timers are now deduplicated so repeated events reuse one pending pass, a background rescan that re-armed itself forever now stops once its work is done, and two setup steps that gave up when combat outlasted their single retry now retry until combat ends. (by Krathe) -* (Click Casting) Applying and clearing bindings now tracks exactly which click attributes were written on each frame instead of a ~500-write blanket sweep, which also fixes the previous profile's action lingering on an unmodified mouse button until reload. (by Krathe) -* (Click Casting) **Removed:** the hidden per-binding "load only for these specs" field, which no settings screen could ever set and which could filter your binding list using unresolved spec data at login — per-spec click casting is unchanged via loadout-assigned profiles. (by Krathe) - - -### Known Issues (12.1 alpha) - -* The 12.1 aura displays are rebuilt on Blizzard's new container system and are under active testing — please report any case where buff, debuff, defensive or missing-buff displays stop updating, **especially in combat**. -* A debuff that counts as both a Priority debuff and a Boss/Role debuff can show one icon per matching filter when both are enabled — the only duplicate case the new filter system can't remove. "Show All Debuffs" avoids it. -* Dispel Overlay: a unit with dispellable debuffs of two different types can show both type icons overlapped (rare). -* Aura Designer text colouring is drawn as a cover over the text: it ignores the out-of-range text fade, and group parts with their own inline colours keep them. -* Dragging certain aura sliders can briefly stutter. -* Settings-window borders re-derive their thickness from DandersFrames' own **UI Scale** slider (top of the settings window). Changing WoW's global UI Scale instead won't re-derive them for pages already on screen — reopen the window, or nudge the addon's own slider, if a border looks off after doing that. - -## [4.8.0] - -### Bug Fixes - -* (Click Casting) Fixed all binds being dead in your first arena or dungeon of a session until a reload. (by Krathe) -* (Pinned Frames) Fixed an empty "drag to move" box getting stuck on screen after a profile switch, surviving reloads. (by Krathe) - -### Improvements - -* (Click Casting) Enabling a targeting fallback on a key that already does something now asks for confirmation and names what the key will stop doing. -* (Click Casting) "Clear Blizzard Bindings" now warns that clearing is permanent, and only clears when you press it. - -## [4.7.5] - -### Bug Fixes - -* (Click Casting) **Fixed keybinds going dead after hovering a party or raid frame (a 4.7.4 regression)** — keys now release the moment the cursor leaves a frame, with extra self-recovery layers if binds ever stick again. (by Krathe) - -## [4.7.4] - -### Bug Fixes - -* (Click Casting) Fixed a Lua error walking Blizzard unit frames after combat that could stop click-casting bindings from working on the default frames until reload — the frame scan now skips protected (secret) values introduced by recent client versions. (by Krathe) -* (Click Casting) Keyboard binds that stop working mid-session now recover automatically after combat ends or a loading screen, instead of staying broken until a reload. -* (Click Casting) **Fixed keyboard and extra-mouse-button hover binds dying addon-wide mid-combat until a /reload** — hover binds are now owned by one permanent frame instead of the unit frame under the cursor, removing the cleanup step that could hit a dead frame reference and silently break every later hover. The automatic self-repair stays as a safety net, but the cause is gone. (by Krathe) -* (Click Casting) Fixed keyboard and mouse-wheel click-cast binds being silently dropped mid-hover during combat — the safety check that removes hover binds could misread the cursor as off the frame while the frame's position was briefly unreadable, wiping the binds until the frame was re-hovered. Binds are now only removed when the cursor is provably off the frame. (by Krathe) -* (Click Casting) Fixed binds on keys from international keyboard layouts (such as æ, ø or å) not casting on the frame under the cursor. -* (Click Casting) New "Always Cast" option in a binding's Advanced settings. When enabled, the key still casts with the spell's normal targeting if no other rule matches — so ground-targeted spells (like aimable Shaman totems) show their aiming circle when pressed while hovering nothing. -* (Click Casting) Frames that other addons register for click-casting are now only taken over if they are real unit frames — non-unit buttons (such as toy or action buttons) are left alone, and frames an addon explicitly unregisters stay unregistered. -* (Auras) Fixed a stream of blocked-action taint warnings in PvP instances (triggered while styling aura duration text) that could spill over and break Blizzard's chat or other addons until a /reload. -* (Bars) **Fixed health bars rendering solid green when a profile references a bar texture you don't have** — imported profiles often point at another addon's texture files; if that addon isn't installed (or its files changed), the bar showed WoW's green missing-texture state and class colours appeared broken. All bar textures now fall back to the stock texture with a one-time warning, on every update path (the fallback previously only applied when a frame was first created and was immediately overwritten). (by Krathe) -* (Bars) Fixed class-coloured health bars staying stuck on the gradient colour (usually green) after using or switching away from the Percent colour mode — the class/custom colour is now written directly to the bar texture, so it can no longer be masked by a leftover gradient tint. (by Krathe) - -## [4.7.3] - -### Bug Fixes - -* (Bars) **Fixed health bars rendering solid green when a profile references a bar texture you don't have** — imported profiles often point at another addon's texture files; if that addon isn't installed (or its files changed), the bar showed WoW's green missing-texture state and class colours appeared broken. All bar textures now fall back to the stock texture with a one-time warning, on every update path (the fallback previously only applied when a frame was first created and was immediately overwritten). (by Krathe) -* (Bars) Fixed class-coloured health bars staying stuck on the gradient colour (usually green) after using or switching away from the Percent colour mode — the class/custom colour is now written directly to the bar texture, so it can no longer be masked by a leftover gradient tint. (by Krathe) - -## [4.7.2] - -### Bug Fixes - -* (Click Casting) Fixed a Lua error walking Blizzard unit frames after combat that could stop click-casting bindings from working on the default frames until reload — the frame scan now skips protected (secret) values introduced by recent client versions. (by Krathe) -* (Click Casting) Keyboard binds that stop working mid-session now recover automatically after combat ends or a loading screen, instead of staying broken until a reload. - -## [4.7.1] - -### Bug Fixes - -* (Text Designer) Element colour changes now take effect immediately while the colour picker is open, instead of only after closing it with OK. (by Krathe) -* (Interface) Opening a colour picker no longer counts as a colour change — the picker fired its change handlers once during setup, which could commit settings (such as a Text Designer element's colour override) without any edit. (by Krathe) -* (Aura Designer) Fixed expiring border animations staying stuck on after the tracked aura was refreshed (for example re-casting a HoT in its pandemic window). (by Krathe) - -## [4.7.0] - -### Bug Fixes - -* (Text Designer) **Restored health text lost when upgrading to 4.6.0** — the legacy-to-Text-Designer conversion keyed "did this profile use health text?" off an internal flag that never actually controlled the old display, so upgrading dropped the health text element for most converting profiles (and its cleanup pass could remove a working health element from profiles converted by earlier versions). The conversion now keys off the real setting — the health text Format, where only NONE means off — and profiles that already lost their health text get it rebuilt from their legacy settings on login. If you had deliberately deleted the auto-created health element, it may reappear once — delete it again and it will stay gone. The stray "%" cleanup now targets only profiles that genuinely had health text disabled. (by Krathe) -* (Profiles) **Selective export/import no longer silently drops settings** — the category lists had drifted behind the addon's settings, so a selective export was missing the entire Targeted List page, the buff/debuff border style and animation families, permanent movers, My Buff Indicators, out-of-range alpha values and several more (~180 settings). All now travel with their categories, and an internal audit keeps the lists complete going forward. Full-profile export was never affected. (by Krathe) -* (Profiles) **Export categories now match the addon's features** — the old "Icons" checkbox silently bundled five whole features (Targeted Spells, Targeted List, Missing Buffs, Defensives and the status icons); Text hid the Text Designer; Auras hid Boss Debuffs. The export/import picker now offers each feature as its own category — Dispel, Boss Debuffs, Missing Buffs, Defensives, My Buff Indicators, Targeted Spells, Targeted List, Text Designer and Status Icons included — so you can share exactly one feature's setup (or exclude one) honestly. Existing export strings keep working unchanged. (by Krathe) -* (Text Designer) **Imported profiles keep their Text Designer configuration** — importing any modern profile with the Text category selected silently wiped the imported Text Designer elements and rebuilt them from the legacy text settings (the legacy-import converter misidentified every modern export as a legacy one). Modern imports now leave the imported Text Designer intact; genuinely old exports still convert as before. (by Krathe) -* (Profiles) **Party/raid section-sync links now export and import** — the per-page "sync party & raid" toggles were left behind by both full and selective export (and reset when importing into a new profile). They now travel with full exports and with the Other category on selective exports. (by Krathe) -* (Debug) **Debug commands tidied and self-documenting** — new `/df help` lists the user commands, and `/df debug` now lists every available debug command with a one-line description (`/df debug on`/`off` controls debug logging explicitly). Support diagnostics (aura, click-casting, header, range and similar state dumps) remain available in all builds for troubleshooting; developer harnesses (performance suite, atlas browser, deep aura tooling and the like) now only register on alpha/beta builds; and nine obsolete one-off test commands from finished development work were removed entirely. (by Krathe) -* (Status Icons) **Role and raid-target icons no longer apply their Scale setting twice** — two update paths each applied the scale, so the icons actually rendered at scale-squared. They now render at exactly the size the slider says; with default settings the raid target icon becomes ~10% smaller and the role icon is unchanged. (by Krathe) -* (Auras) **"Hide Duration Above" no longer silently breaks after using Test Mode** — the aura duration text is now set up through one shared path. Previously, opening test mode (or dragging a duration slider before any timed aura had displayed) could disconnect the text from the visibility control that implements the threshold, so durations stayed visible above your cutoff until a reload. The buff/debuff border type colours also now come from one shared palette in test and live. (by Krathe) -* (Interface) **Mode accent now updates on the toolbar and Test panel buttons** — the changelog button at the top of the settings window and the Test panel's Enable/Disable button kept the hover tint of whichever mode they were first shown in, so they could flash the party accent in raid (or vice-versa). They now follow the current party/raid accent like every other control. (by Krathe) -* (Test Mode) **Absorb, heal-absorb and heal-prediction previews now render through the real bar code** — test mode used to hand-draw simplified copies of these bars, which could drift from what you actually see in dungeons (and did, before the recent pixel-perfect fixes). The previews are now pixel-identical to live by construction, including attached/overflow modes and the overshield glow. (by Krathe) -* (Test Mode) **Defensive bar preview now positions through the real layout code** — the test icons previously used their own copy of the grid layout that skipped pixel-perfect scale folding and pixel-grid snapping, so the preview could sit slightly off from live placement. One shared layout path now serves both. (by Krathe) -* (Internal) **Code cleanup, round one** — removed ~130 unused functions (including two entire superseded dialogs), 6 dead settings keys and ~140 orphaned locale strings identified by the 2026-07 code audit; raid defaults are now generated from party defaults (they were a 1,250-line hand-maintained copy that could silently drift); the live colour picker moved out of its misleadingly-named "Test" file; binding-row icons resolve through one shared helper. No behaviour change. (by Krathe) -* (Auras) **Removed the obsolete Blizzard aura source** — WoW 12.0.5 removed the API it relied on, and every profile has been force-migrated to the Direct API source since v4.2.x. The Aura Filters page loses its dead "Source Mode" dropdown (whose choice was silently reverted every login), and the setup wizard now goes straight to filter configuration. Direct API is simply how auras work now. (by Krathe) - -## [4.6.0] - -### Interface Overhaul - -A top-to-bottom rework of the configuration UI so every panel shares one consistent look and behaviour. - -* (Interface) **One shared widget toolkit** — buttons, checkboxes, dropdowns, sliders, range sliders, text inputs, colour pickers, tooltips, info banners, tab strips and panel/section backdrops are now built from a single themed set of helpers instead of dozens of hand-rolled one-offs, so every control looks and behaves identically across the addon (consistent accent colour, hover and disabled states). (by Krathe) -* (Interface) **Disabled settings grey out in place, addon-wide** — when a feature is turned off its options stay visible but dimmed and inactive (a consistent preview) instead of staying clickable or disappearing. (by Krathe) -* (Aura Designer / Text Designer) **Unified look** — both designers now share the same panel layout, preview frame, tab strip and column styling as the rest of the UI, so they no longer feel like separate tools. (by Krathe) -* (Pinned Frames) **Reworked to match the standard UI** — the tab/sub-tab strip, designer-preset bar and grouped settings boxes now use the shared styling. (by Krathe) -* (Nicknames) **Page rebuilt** — restructured into grouped section boxes (Add / Saved / Received / Marker / Name Precedence) consistent with the other feature pages. (by Krathe) -* (Interface) **Consistent tabs, tooltips, banners & section boxes everywhere** — Options, Click Casting, Search, Auto Profiles, Test Mode and the icon library now share one tab style, one tooltip system, and the same info banners and settings-group boxes. (by Krathe) -* (Interface) **Material Symbol icons** normalised to a consistent size and padding, with new icons added. (by Krathe) -* (Interface) **Wording & value consistency** — standardised inconsistent labels and dropdown values across the settings UI; the Settings-panel font outline now offers its full option set. (by Krathe) -* (Localization) Wrapped the remaining hardcoded UI text so settings labels and Click Casting messages translate properly. (by Krathe) - -### New Features - -* (Highlights) New **Hide on Tanks** option — suppresses the aggro highlight on tanks so it only flags when a DPS/healer pulls. Off by default. (by Krathe) -* (Pet Frames) Added an optional **power bar** for pet frames, with adjustable height and colour. Off by default. (by Krathe) -* (Pinned Frames) Pinned sets can now **anchor to your raid/party frames** instead of the screen, tracking them as they move or resize. (by Krathe) - -### Improvements - -* (Auto Layouts) Moving raid frames while an auto layout is active now edits **that layout**, via its own Unlock button. (by Krathe) -* (Pinned Frames) Each set's **Horizontal/Vertical Spacing** now inherits from the based-on mode, overridable per set. (by Krathe) -* (Options / Click Casting) Inline "Note:" labels are now consistent and no longer show a stray "?". (by Krathe) -* (Interface) **Notes, tips and banners now share one colour system** — caveats, tips, recommendations and warnings across the settings UI are coloured from a single set of tones (info, caution, danger, success) instead of assorted one-off colours, so a note's importance reads the same everywhere. (by Krathe) -* (Interface) Drag-to-reorder lists (role, class and group order) now use a clearer reorder-handle icon. (by Krathe) -* (Pixel Perfect) The Display page now shows the **UI Scale** that gives exact 1:1 pixel rendering at your resolution (or confirms you're already there) — purely informational, it never changes your scale. (by Krathe) -* (Click Casting / Aura Designer) The **Priority** sliders now read left-to-right as Low → High — **higher number wins**, matching every other slider (right = more). Your existing priorities are migrated automatically, so only the displayed number changes, not which binding or aura takes precedence. (by Krathe) - -### Bug Fixes - -* (Fonts) **A corrupted font setting can no longer break the addon on login** — font name, size and outline values are validated before use, so a bad value in an imported or hand-edited profile now degrades to default text styling instead of erroring out the entire frame layout (which showed up as missing debuffs, broken test mode and blank settings values). (by Krathe) -* (Pinned Frames / Test Mode) Fixed a Lua error that spammed and froze the UI (forcing a reload) when previewing pinned frames containing NPC/boss units — role icons no longer error on units without a role. (by Krathe) -* (Sorting) Fixed the combat-status banner on the Sorting page sometimes appearing as a blank white box instead of its coloured status. (by Krathe) -* (Raid) Test mode and settings changes can no longer disturb your **live** raid frames — group order and positions are always driven by the secure layout, fixing the grouped-raid inversion that previously needed a `/reload`. (by Krathe) -* (Raid) Retired an obsolete internal reverse-group-order setting that could invert raid groups; group order is controlled entirely by **Group Display Order** / **My Group First**. (by Krathe) -* (Pet Frames) Fixed a combat error (blocked action) when a pet appeared, died, or was dismissed during combat — pet frames now hide combat-safely. (by Krathe) -* (Borders) **Border Inset** is now honoured by texture-style borders, and updates live. (by Krathe) -* (Shields) Absorb and heal-absorb overlays now cover the health bar with **no gap** — flush to the edge when the frame border is opaque or off, keeping a small inset only when the border is translucent (so the shield doesn't show through it). (by Krathe) -* (Pixel Perfect) **Center-aligned** frames now snap to the physical pixel grid, so borders stay crisp instead of doubling or dropping a side — party frames live, and raid frames in test mode (live raid keeps Blizzard's secure positioning). The overshield line indicator and the solid highlight border snap to whole pixels too. (by Krathe) -* (Text) Fixed a stray health value (often a "%") appearing for users who never turned health text on. (by Krathe) -* (Nicknames) Fixed an error matching nicknames against boss/NPC names on pinned frames in encounters. -* (Localization) Test Mode, position, grid, Text/Aura Designer and auto-profile labels now translate properly. -* (Raid) Test mode now matches live raid frame positions in flat and grouped layouts, including Center alignment. (by Krathe) -* (Raid) Group labels no longer linger over the flat raid grid after switching from a grouped layout. (by Krathe) -* (Defensive Icons) Fixed click-through not applying to stacked defensives that appear mid-combat. (by Krathe) -* (Aura Designer) Health-bar **Tint** now fills the whole bar instead of leaving an edge gap under a border. (by Krathe) -* (Aura Designer) Fixed aura **borders** showing the wrong colour/thickness on imported or older profiles. (by Krathe) -* (Aura Designer) Fixed imported/older auras sometimes showing **no indicators** until re-saved. (by Krathe) -* (Aura Designer) The fill-**Bar** indicator's expiring **Tint** controls are now available in the editor. (by Krathe) -* (Aura Designer) Fixed **Import Buffs Tab Defaults** not carrying the buff border toggle to new icon auras. (by Krathe) -* (Resource Bar) **Smooth Bar Animation** now actually smooths the bar's fill. (by Krathe) -* (Buffs / Debuffs) Fixed pixel-perfect aura icons being mis-sized when a custom **Border Thickness** was set. (by Krathe) -* (Status Icons) The **Frame Level** slider now previews live while dragging. (by Krathe) -* (Profiles) The **Border Color Source** (Class/Role) for Missing Buff/Defensive icons is now kept on single-category export/import. (by Krathe) -* (Click Casting) A binding's **combat condition** now shows in the list immediately instead of after a reload. (by Krathe) -* (Debuffs) Removed the defunct **Highlight Dispellable** toggle (handled by Dispel Overlay). (by Krathe) -* (Pet Frames) Pet **name and health text show again** (font/anchor/colour settings now apply). (by Krathe) -* (Pet Frames) An empty party pet frame no longer lingers over the raid frames when a member summons a pet in a raid. (by Krathe) -* (Global Font) **Apply to All** now also restyles the Text Designer text (Name / Health / Status and custom elements); the Affected Elements list was updated to match. (by Krathe) -* (Raid) The frame **Unlock/Lock** toolbar button now updates its label correctly in raid mode (it previously stayed on "Unlock" after unlocking). (by Krathe) -* (Auto Layouts) The Mythic layout's fixed-range badge no longer overflows its button. (by Krathe) -* (General) Fixed the doubled "v" in the version line printed on login. (by Krathe) - -## [4.5.0] - -### New Features - -* (Targeted Spells) On-frame targeted spell icons are back for **party frames** — an icon appears on the party member an enemy is casting at, so you can spot incoming danger at a glance. Works in dungeons, arenas, and the open world; enable it under **Indicators → Targeted Spells**. Party members who share the same class, role, race, and sex can't be told apart and won't show an icon — you'll be warned by name when that happens. Technique credit to Ellesmere (EllesmereUI). Raid frames aren't supported. -* (Aura / Text Designer) New **Designer Presets** — save named presets of your Aura Designer and Text Designer setups and choose which one each mode uses. Each **Party / Raid** tab gains a **Preset** selector plus **New / Duplicate / Rename / Delete** (a non-deletable **Default** is always available), so you can build different setups — a focused tank layout vs a minimal one, say — and switch between them without rebuilding. Raid auto-layouts can point at their own preset, and editing a preset updates every consumer using it. (by Krathe) -* (Pinned Frames) You can now have **more than two pinned sets** — up to four, and **Party and Raid are independent** (e.g. four sets in raid, one in party). The set editor gains a **+ Add** button to create a new set and an **×** on the active tab to remove one (the last set can't be removed). A counter shows how many sets are defined and how many are currently active, since each enabled set is a live frame group. Use **Copy Pinned Frames** if you want both modes to match. -* (Pinned Frames) New per-set **Hide Auras** and **Hide Status Icons** toggles — strip the buff/debuff (and Aura Designer) display or the status icons from a pinned set for a clean, health-only highlight. (by Krathe) -* (Pinned Frames) New **Exclude Self** option per pinned set — keeps you out of that set's Auto-add (Tanks/Healers/DPS), handy for Augmentation Evoker and other buff-others playstyles. Works in groups and solo. (by Krathe) -* (Pinned Frames) New **Disable in PvP** toggle (on by default) — pinned frames stay dormant in arena and battlegrounds, where the constant unit churn could otherwise hurt performance. Turn it off to highlight teammates there. (by Krathe) -* (Pinned Frames) Each pinned set can now choose its own **Aura Designer** and **Text Designer preset** — pick one in Frame Style, or leave it on **Inherit** to follow the mode's preset. Lets a highlighted set show a different aura/text layout from your normal frames. (by Krathe) -* (Pinned Frames) Pinned frames are now **independently customisable** per party/raid mode: a **Frame Style** section with a **Based on** selector (Party / Raid, defaulting to their own mode) sets the inherited look, and the settings that also exist on your main frames — **Width**, **Height** and **Scale** — inherit the chosen frames' value and can be overridden per set, each showing a star and a reset button, just like an auto-layout override. An **Override Border** toggle lets a set take its own border/glow (snapshotted from the chosen frames, then fully editable, with a one-click reset to re-inherit) to make highlighted players stand out. A party-only **Show in Solo Mode** toggle keeps them visible when you're not grouped. (by Krathe) - -### Improvements - -* (Targeted Spells) Tuned the default look of the important-spell **Highlight** for both party and personal targeted spells — a thinner dashed border with a softer alpha, plus refreshed spacing/offset defaults. Only affects new profiles; existing setups are unchanged. -* (Pinned Frames) Pinned frames now **lock and unlock together with your main frames** — there's no separate per-set Lock Position toggle any more. Unlock your party or raid frames and the pinned sets for that mode show their drag handles too; lock again and they all settle. Each set's handle also opens the same **fine position panel** as the party/raid frames (precise X/Y nudge, centre, reset), so you can place a pinned set to the pixel. (by Krathe) -* (Pinned Frames) Pinned frame settings are now **global per party/raid mode** and no longer saved into auto layouts — a raid auto layout only controls whether each pinned set is **shown** for that layout. This removes the stale/blank pinned data and editor mismatches that came from pinned settings being stored per-layout, and pinned edits now take effect live. (by Krathe) -* (Click Casting) The binding editor no longer shows the Advanced options section for Target Unit and Context Menu binds — those settings don't apply to those actions. -* Refreshed the DandersFrames addon icon. -* (Out of Range) The out-of-range text fade is now a single **Text Alpha** control that dims the whole Text Designer overlay together, replacing the old per-element Name/Health alpha sliders that no longer did anything once the Text Designer took over text rendering. (by Krathe) -* (Targeted Spells) The party and personal targeted-spell icons now use the **full border toolkit** — style, colour, alpha, inset, blend mode, shadow and animation — the same controls as the rest of the addon, and the important-spell highlight is now its **own border** you can style separately. Existing highlight settings carry over. (by Krathe) - -### Bug Fixes - -* (Status Icons) The drop shadow shows again on the **AFK timer** and **all status-icon text**. A 12.0.7 font change stopped the old fontstring-level shadow from rendering, so status-icon and timer text lost their shadow while the rest of your text kept it; they now apply the shadow through the font itself like everything else. The global **Shadow X / Y / Color** sliders also preview live again while dragging — on live, pinned, and test frames — instead of only after a reload. (by Krathe) -* (Borders) Animated borders (dashed / marching-ants and the other motion effects) no longer freeze after a frame hides and re-shows, or an aura icon is recycled from the pool — they kept their "already running" marker even though the underlying animation had stopped, so they sat static until a setting was nudged to force a restart. (by Krathe) -* (Borders) Fixed pixel-perfect borders dropping a side, switching sides when an icon moved, or looking thicker than their set size. Icon/frame edges are now snapped to the physical pixel grid, so a 1px border lands cleanly on a single row of pixels instead of straddling two. Also fixes a small icon-to-border gap that showed on freshly-added Aura Designer indicators in the preview. (by Krathe) -* (Targeted Spells) The on-frame targeted-spell duration timers now count down in Test Mode instead of sitting on a frozen value, so you can preview the look while configuring them. (by Krathe) -* (Aura Designer) Fixed a Lua error that could spam in combat when an aura indicator used a **Gradient** border together with **Expiring Colour Override** — the expiring colour resolves through the aura's protected duration in combat, and the border's gradient-clear step rejected it. The border now recolours cleanly. (by Krathe) -* (Pinned Frames) Text Designer elements now update on pinned frames right away when you add or edit them, instead of only showing up after toggling test mode. -* (Raid) **Group Display Order** and **My Group First** now reposition the frames live: changing the order (or toggling My Group First) moves the raid frames immediately instead of only moving the group labels and leaving the frames in default order until the next roster change. The **My Group First** setting also now saves when toggled while editing an active auto layout, instead of being lost on reload. (by Krathe) -* (Arena) Fixed teammates who load in late sometimes staying missing for the whole round, the frame order breaking after a mid-match reload, and frames staying hidden after a reload during a match. (by Krathe) -* (Range) The frame border (and other element borders) now reliably **fade out of range**, preserved across border re-renders. (by Krathe) -* (Missing Buff) The missing-buff icon no longer flags a **cross-faction group member in the open world** as needing a buff you can't actually cast on them — it only appears where the buff is castable (e.g. inside instances). (by Krathe) -* (Defensive Icon) The defensive cooldown icon and its border now render **above auras** and stay co-planar with the icon. (by Krathe) -* (Role Icons) **Show Tank / Healer / DPS** toggles now apply live without a `/reload`, and are properly decoupled from the Hide-in-Combat gate. (by Krathe) -* (Raid Frames) Fixed missing frames for players who are still loading in, such as battleground backfills joining mid-match. (by Krathe) -* (Aura Designer) The **Name Text** and **Health Text** indicators (recolour the unit's name/health text on aura presence) work again now that the Text Designer owns that text — they recolour the Text Designer's name/health elements, including the expiring colour fade. (by Krathe) -* (Aura Filters) Removed the "Important Spells" buff and debuff filter, which Blizzard removed from the game. -* (Click Casting) Fixed Target Unit and the unit menu not working when bound to a keyboard key or an extra mouse button since the 12.0.7 patch — now fixed on DandersFrames, Blizzard's default unit frames, and other unit-frame addons. Technique credit to Ellesmere (EllesmereUI). -* (Buff/Debuff) **Show Buffs / Show Debuffs** toggles now apply live without a `/reload` — already-shown auras hide or reappear immediately instead of lingering until the unit's next aura change. (by Krathe) -* (Pinned Frames) Pinned frames now reappear when you zone into a battleground or arena, instead of staying hidden until a `/reload`. (by Krathe) -* (Pinned & Raid Frames) Fixed frames cascading into a diagonal "staircase" when switching their grow direction between Horizontal and Vertical — the layout now re-flows cleanly without a `/reload`. Pinned layout changes (direction, spacing, size) made during combat are also applied when combat ends, instead of being dropped until the next change. (by Krathe) -* (Borders) A border set to thickness **0** now hides cleanly across every style, while any border animation keeps running. (by Krathe) -* (Borders) Fixed a doubled glow that could appear on the **PROC** border animation when it re-triggered; the PROC start-flash is now an opt-in option. (by Krathe) -* (Personal Targeted Spells) The personal targeted-spell display now honours your **raid** and **party** settings separately — disabling it on the raid tab no longer leaves it showing in raid (the whole feature was reading the party settings, ignoring raid's enable / size / position / border). (by Krathe) - -## [4.4.1] - -### Bug Fixes - -* (Text Designer) Fixed the Text Designer being left out of the 4.4.0 release, which left its settings page empty and could make frame text disappear. It is now included and working. - -## [4.4.0] - -### New Features - -* **Text Designer** is now available in all versions (previously alpha-only) — add any number of custom text elements to your unit frames, each with its own font, size, colour (or class colour), anchor and position. Elements can show live data: identity (name, class, level, race, faction, group number, custom text), health, power, shields & heals, dead/offline status, threat and range. Find it under the **Text** tab. -* (Frames) **Unified border system** — every border (frame, buff/debuff icons, aura bars, defensive icons, missing-buff, resource bar, pet frames, targeted spells) now runs through one engine with consistent **Style / Colour / Alpha / Gradient** controls. (by Krathe) -* (Borders) Added optional **border animations** — 10 effects (pulse, wipe, ripple, segment reveal, sides/corners-only, proc glow, dash, and more), available wherever a border is drawn. (by Krathe) -* (Icons) Status icons now use crisp **modern Blizzard atlas art** (ready check, summon, resurrect, phased, vehicle, main tank/assist, AFK), with automatic fallback to the legacy texture. (by Krathe) -* (Icons) Each status-icon section header now shows a **live preview** — the icon swatch, or its status text when "Show as Text" is on. (by Krathe) -* (Icons) New **BG objective carrier icon** — lights up a friendly party/raid member carrying a battleground objective (flag or orb). (by Krathe) -* (Role Icon) **Custom role icons** — choose Blizzard, DF, or your own external texture per role (Tank / Healer / DPS). (by Krathe) -* (AFK Icon) Dedicated **Timer Text** controls for the elapsed-time counter (font, size, outline, colour, offset). (by Krathe) -* (Fonts) Bundled **Roboto Mono** (SemiBold/Bold) — a monospaced option for perfectly static countdown text. (by Krathe) -* (Icons) New **in-combat indicator** — a small crossed-swords icon lights up when a unit is in combat. Off by default, with its own position and size controls. (by Krathe) -* (Auto Layouts) Added `/df clearoverride ` to **remove a stuck per-layout override** the settings UI can't reach. (by Krathe) -* (Nicknames) New **Nicknames** feature — show custom names on your party and raid frames. Build a prioritised list from your friends, guild and group, or type names in by hand, and choose how matches are made. Optionally mark nicknamed players with brackets (or other styles) and share your own nickname with the group. Find it under **General → Nicknames**. (by Maelareth) -* (Nicknames) The **Add from** lists (friends, guild, group, Battle.net) put **online members first**, and you can **favourite** people with the star to pin them to the very top. (by Maelareth) -* (Resource Bar) Added a **Color Mode** (Power Type / Class / **Custom**) with a custom-colour picker, and a **Texture** dropdown so the resource bar can use any statusbar texture. (by Krathe) -* (Aura Designer) New **Background Color** effect — colour a frame's background when an aura is active (Replace or Tint), with the same Expiring colour override, Pulsate and out-of-range handling as the other effects. (by Krathe) -* (Missing Buff) The missing-buff border can now use **Class or Role colour** instead of a single fixed colour. (by Krathe) - -### Improvements - -* (Performance) The expiring-border ticker now **throttles and staggers per entry** to cut overhead when many borders are expiring at once. (by Krathe) -* (Defaults) Tuned some new-profile defaults — buff icon sizing/spacing, stack-count offsets, text outline shadows, and indicator border insets. (by Krathe) -* (Reduced Max Health) The bar's default colour is now a **translucent grey** instead of opaque black so it reads clearly on a dark health bar; profiles still on the old default are migrated automatically. (by Krathe) -* (Boss Debuffs) **Border Scale** can now go negative to hide the icon border. (by Krathe) -* (Icons) Reorganised **every status-icon's settings into collapsible Settings / Appearance / Position boxes**, and changes now apply to live frames instantly — no `/reload`. (by Krathe) -* (Icons) Renamed **"Raid Target Icon" → "Target Marker Icon"**, with a header preview showing the four common markers. (by Krathe) -* (Test Mode) The separate **Status / Ready** and **Role / Leader** preview toggles are now a single **Icons** toggle, matching the live status-icon grouping. (by Krathe) -* (Auto Layouts) The **override tooltip and `/df overrides` now read clearly** — each changed setting shows as a breadcrumb path with its value, and the override counts agree everywhere. (by Krathe) -* (Aura Designer) Expiring health-bar highlights now **pulse in unison** across all frames instead of each frame pulsing on its own timing. (by Krathe) -* (Aura Designer) The health-bar indicator's **Tint** mode has a new **Tint Entire Bar** option — tint the whole bar including the missing-health portion. (by Krathe) -* (Text) The built-in Name, Health, and Status text settings are now handled entirely by the **Text Designer** — the older text pages are hidden and your existing text is converted to Text Designer elements automatically. Customise all frame text under the **Text** tab. -* (Resource Bar) Added **Maelstrom** and **Pain** to the per-power resource colour options, so Shaman and Vengeance Demon Hunter resource bars can be recoloured. (by Krathe) -* Moved **Pixel-Perfect Scaling** to **General → Settings**, since it applies globally to both party and raid. (by Krathe) -* (Nicknames) **Northern Sky Raid Tools compatibility** — when NSRT is also set to put nicknames on DandersFrames frames, a one-time prompt lets you choose which addon decides the names shown on your frames (changeable later under **General → Nicknames → Name precedence**). (by Maelareth) -* (Aura Designer) Alert sounds now play on the **Master** channel by default, so they stay audible even with Sound Effects or Music muted; a new **Channel** dropdown lets you pick a different channel. (by Krathe) -* Ready for **patch 12.0.7** — the addon-compartment tooltip and the frame mover's Ready Check action keep working on the new patch. (by Krathe) - -### Bug Fixes - -* (Pinned Frames) Fixed a freeze ("script ran too long") when entering an arena or battleground; pinned frames now stay dormant in instanced PvP, with an option to re-enable them. (by Krathe) -* (Sorting) Group frames no longer **reshuffle mid-fight** when members share the same sort position, and hidden unit information during **Mythic+ encounters** no longer causes errors. (by Krathe) -* (Status Icons) Fixed an error from **AFK tracking** during Mythic+ encounters. (by Krathe) -* (Resource Bar) The colour now updates correctly when your **power type changes** (for example, on shapeshift). (by Krathe) -* (Auto Layouts) While in a raid you can now only edit the **active layout**, so editing no longer disturbs your live raid frames. (by Krathe) -* (Aura Designer) Fixed frame-level effects sometimes **missing from the live preview**. (by Krathe) -* (Defensive Icons) Fixed icon positions **drifting** from where test mode shows them at non-default icon scales. (by Krathe) -* (Reduced Max Health) The reduced-max-health overlay now follows the **health bar's orientation**, including vertical bars. (by Krathe) -* (Range) Frame and element borders — including Aura Designer border effects — now reliably **fade out of range**. (by Krathe) -* (Missing Buff) No longer flags a **cross-faction group member in the open world** for a buff you can't cast on them. (by Krathe) -* (Defensive Icon) The defensive cooldown icon and its border now render **above auras**. (by Krathe) -* (Role Icons) **Show Tank / Healer / DPS** toggles now apply live without a `/reload`. (by Krathe) -* (Aura Designer) Indicators are torn down when the Aura Designer is disabled, and re-applied on **profile swap**. (by Krathe) -* (Targeted Spells) The targeted list no longer appears in **test mode** when the feature is disabled. (by Krathe) -* (Aura Designer) Replace-mode health-bar highlight fixes — no more **flicker** on phased or out-of-range units, no **bleeding over the frame border**, and the expiring **Pulsate** option now works. (by Krathe) -* (Test Mode) Replaced several test-mode buff/debuff preview icons that pointed at art removed in Midnight, so they no longer render blank. (by Krathe) -* (Designers) The Aura/Text Designer preview now rebuilds to the **frame size of the auto layout being edited**, and edits keep applying to test-mode frames after **switching between auto layouts**. (by Krathe) -* (Frames) Bars referencing a **texture you don't have installed** now fall back to the bundled default with a one-time notice, instead of rendering black. (Requires WoW 12.0.7.) (by Krathe) - -## [4.3.12] - -### New Features - -* (Heal Prediction) Added a "Show Heals From" option — All Incoming, My Heals, Others' Heals, or Split — letting you choose which incoming heals the bar shows. Split draws your heals and other healers' heals as separate coloured segments. (PR #109 by Krathe) -* Added a `/df attached` command that lists other addons attaching castbars, cooldowns or auras to your frames — handy for telling apart DF features from another addon's. (PR #104 by Krathe) -* (Click Casting) Added a **"Target unit when click-casting"** option — click-casting a spell on a frame can now also make that unit your target. Set it globally in the Click Casting profiles panel, or override it per-binding under Advanced → Macro Options. (PR #116 by Krathe) -* (Fonts) Expanded text styling across every font setting: added **Monochrome**, **Monochrome Outline**, and **Monochrome Thick Outline** outline styles; a drop **shadow can now be combined with any outline** (each font now has its own Shadow checkbox) instead of being mutually exclusive; and a new **Crisp Font Rendering (SDF)** option in Global Fonts smooths text edges for sharper text. (PR #115 by Krathe) -* (Frames) Added border customization under Frame → Appearance: a **Border Style** option (Solid or Texture), where the Texture style can use any border registered with SharedMedia (the built-in set, or ones added by other media addons), plus a **Border Size** slider to adjust thickness. (PR #119 by Krathe) -* (Frames) Added a **Use Class Color** option for the frame border, colouring each frame's border by the unit's class. (PR #120 by Krathe, suggested by maelareth) -* (Frames) Added bundled frame border textures — **DF Glow**, **DF Bevel**, **DF Inset**, and **DF Double** — selectable under the Texture border style (and shared with other media-aware addons). Designed with square corners for rectangular frames; the bevel/inset shade with the chosen border colour for a 3D edge. (PR #121 by Krathe) - -### Bug Fixes - -* (Absorbs) Fix the absorb / heal-absorb / heal-prediction bars leaving a sliver of the health bar showing when Reduced Max Health (Clip Health Bar) and a frame border were both enabled. The bars were inset by the border width even at the internal clip edge, where there is no border. (PR #124 by Krathe) -* (Absorbs) Fix the overflow absorb bar not fading out of range when using the Attached + Overflow absorb style. (by Krathe) -* (Aura Blacklist) Replaced the unclear "OOC" toggle label with clear "Combat" and "Out of Combat" column headers. (PR #111 by Krathe) -* (Aura Blacklist) The class dropdown now uses the standard arrow instead of a stray yellow one. (PR #111 by Krathe) -* (Aura Designer) Fix the colour picker's alpha not affecting the health bar indicator on live frames, and fix the Blend % slider reappearing in Replace mode after closing and reopening the GUI. (PR #102 by Krathe) -* (Aura Designer) Fix the health bar indicator's expiring colour ignoring its own alpha — it reused the base colour's alpha instead. The expiring colour now uses its configured transparency, in both Tint and Replace modes. (PR #117 by Krathe) -* (Aura Designer) Colour pickers now offer a Default button that resets to the configured default, matching the rest of the addon. (PR #103 by Krathe) -* (Aura Designer) Fix a custom Replace-mode health bar colour flickering to full opacity on every health change. (by Krathe) -* (Auto Layouts) Fix editing a raid auto layout while not in a raid enabling the party Pinned Frames — the pinned-frame refresh targeted the live group's mode (party) instead of raid. (PR #125 by Krathe) -* (Boss Debuffs) Fix private aura anchors remaining bound to the previous occupant of a unit slot after a roster change, causing icons to not appear on replacement players for the rest of the session. (PR #112 by Krathe) -* (Class Colors) The class colour list is now a single section instead of being split into two columns. (PR #111 by Krathe) -* (Click Casting) Renamed the "Cast on DOWN" option to "Cast on mouse down" and added a tooltip, clarifying that it only affects mouse clicks on frames (keyboard binds are unaffected). (PR #118 by Krathe) -* (Click Casting) Trinket and item keybinds now cast on mouseover, target, or self when those fallbacks are enabled — previously they always cast on your current target regardless of fallback settings. -* (Frame Movers) Fix the raid frames' unlock "Drag to move" text using a fixed fallback font instead of your Settings Font, so it now matches the party mover. (PR #122 by Krathe) -* (Heal Prediction) Fix the incoming-heal bar not keeping its size accurate as a unit's health changes while being healed. (PR #105 by Krathe) -* (Indicators) Renamed "Status Icon Text Settings" to "Icon Text Settings" for clarity. (PR #111 by Krathe) -* (Performance) Reduce the frame-rate hitch when joining a group: spec/talent update events fire in bursts as the client syncs data, and each one was triggering a full frame relayout. These are now coalesced into a single refresh. (PR #128 by Krathe) -* (Pet Frames) Fix pet frames not attaching to the correct owner when custom sorting is enabled. (PR #108 by Krathe) -* (Pet Frames) Fix pet frames reappearing after being turned off until a reload. (PR #108 by Krathe) -* (Raid Frames) Fix grouped raid frames briefly jumping when interacting with the settings window: a redundant layout pass was re-firing the secure reposition even when nothing about the raid had changed. It now skips the reposition unless the sort order, group population, or layout actually changed. (PR #129 by Krathe) -* (Raid Frames) Fix the Rows/Columns Grow From setting not always taking effect immediately when changed. (PR #134 by Krathe) -* (Range) Fix the frame border not fading out of range when Element-Specific Alpha is enabled — it was the only element the per-element fade didn't cover. Added a Border Alpha slider to control it. (PR #123 by Krathe) -* (Settings) Fix the Show Minimap Button and Hide Blizzard Player Frame toggles doing nothing when changed in Raid settings. They are now single global options on the Settings page, and the Visibility tab (whose options only apply to party/solo frames) is hidden in Raid mode. (PR #110 by Krathe) -* (Settings) Made the raid layout positioning options consistent between Group and Flat modes: Start/Center/End options are now labelled "Alignment", directional options use real directions (Left/Right or Top/Bottom), and these labels and their values now update live when you change the layout direction instead of needing the settings window reopened. (PR #114 by Krathe) -* (Test Mode) Fix grouped raid frames in vertical layout with Players Grow From set to End: frames no longer overflow below the anchor box, and the column growth direction is no longer inverted, so test mode now matches live frames. (PR #113 by Krathe) -* (Test Mode) Switching between Party and Raid in the settings window while test mode is on now keeps test mode active and moves it to the selected mode, instead of turning it off. (PR #126 by Krathe) -* (Test Mode) Fix frame padding not applying consistently to test frames — the health bar inset now updates on every test frame like it does on live frames, instead of only on frames with reduced-max health. (PR #127 by Krathe) - -## [4.3.11] - 2026-05-21 - -### Bug Fixes - -* (Settings) Fix changes such as adding an auto layout not appearing in the settings window until a reload, caused by the page-caching change. (PR #106 by Krathe) -* (Settings) Fix the sidebar's expanded/collapsed categories not updating to match the new profile when switching profiles with the settings window open. (PR #107 by Krathe) - -## [4.3.10] - -### New Features - -* Added DandersFrames to the Blizzard addon compartment — left-click opens settings, right-click toggles solo mode. Also correctly listed under Unit Frames in the addon list. (PR #97 by Krathe, suggested by JPEscher) -* (Click Casting) Added a "Disable only while flying" option to the Profiles panel. Disables click-casting only when airborne (including druid flight form), while keeping it active on ground mounts. Mutually exclusive with the existing "Disable while mounted/flying" option. (PR #96 by Krathe, suggested by JPEscher) -* (Colour Picker) Added a Default button to the DF colour picker that resets the colour to its configured default value. Also populates ElvUI's Default button on the native picker for DF settings. (PR #95 by Krathe) -* **Reduced Max Health bar** — a new sub-bar appears on the right edge of party and raid frames when a unit's maximum health has been temporarily reduced (e.g. by certain Mythic+ affixes or boss debuffs), showing exactly how much of the bar is locked out. Customise it under **Health Bar → Reduced Max Health**: enable/disable, choose a texture and colour, set a blend mode, and toggle whether the health bar visually shrinks to fit (Clip Health Bar) or the reduced section overlays it. - -### Bug Fixes - -* (Aura Designer) Banner controls no longer overlap when the settings window is narrow. (PR #81 by Krathe) -* (Click Casting) Fix keyboard binds occasionally firing your action bar spell instead of the click-cast spell while the cursor is still on the frame. The mouseover-state guard now requires both checks to agree the cursor has actually left before clearing bindings. -* (Class Power) Fix Size, Colors, Position, and Show for Roles section headers showing as floating labels when Class Power Pips is disabled. (PR #82 by Krathe) -* (Debuffs) Fix debuff icons remaining visible on frames after a unit dies. (PR #85 by Krathe) -* (Defensive Icons) Fix icon borders missing on one or more sides when Pixel Perfect is enabled. (PR #79 by Krathe) -* (Dispel Overlay) Fix gradient snapping to full brightness after a unit goes out of range and back in range while they have a dispellable debuff. (PR #89 by Krathe) -* (Export Settings) Fix the "All" preset unchecking Pinned Frames, Aura Designer, and Auto Layouts. (PR #78 by Krathe) -* (Frame Movers) Fix the orange and blue frame mover outlines rendering above the settings GUI, not following the raid frames when clicking Edit on an Auto Layout, and the position panel X/Y values not refreshing on Auto Layout edit. (PR #93 by Krathe) -* (Global Fonts) Fix the Affected Elements list missing entries and being clipped. (PR #76 by Krathe) -* (Global Fonts) Fix the font dropdown showing the previous profile's font after switching profiles. (PR #77 by Krathe) -* (Leader / Assist Icon) Fix the leader and assist icons not appearing on frames straight away when someone is given assist or the group leader changes. -* (Missing Buff Indicators) Fix a missing buff icon staying on your own frame after entering a delve and rebuffing. (PR #99 by Krathe) -* (Missing Buff Indicators) Fix stale icons remaining on frames after leaving a group. (PR #75 by Krathe) -* (Name Text) Fix names occasionally not appearing on frames, most commonly after a wipe or a roster change. -* (Pinned Frames) Fix all settings showing as overridden by an Auto Layout when only one setting is actually overridden. (PR #91 by Krathe) -* (Pinned Frames) Fix pinned frame position being lost when an Auto Layout is active and the layout rebuilds, or resetting when clicking Edit on an active Auto Layout. (PR #90 by Krathe) -* (Range Fading) Fix out-of-range members briefly flashing at full alpha when joining a group or when the roster reshuffles. (PR #84 by Krathe) -* (Reset Page) Fix Buffs and Debuffs Enable toggles not resetting, plus name truncation and the minimap button now refresh live after a profile reset. (PR #83 by Krathe) -* (Resource Bar) Fix a 1-pixel gap appearing on one side when "Match Health Bar Width" is on with Pixel Perfect. (PR #80 by Krathe) -* (Search) Fix settings edited from search results not firing their setting-specific update for any widget type (checkboxes, sliders, dropdowns, colour pickers, toggle switches) — e.g. the minimap button toggle had no effect when used from search. (PR #98 by Krathe) -* (Settings) Reduce FPS loss when dragging or scrolling the settings window by caching each settings page after its first build. (PR #101 by Krathe) -* (Test Mode) Fix locking frames turning off test mode when it was already active. (PR #87 by Krathe) - -## [4.3.9] - -### Bug Fixes - -* (Aura Designer) Fix health bar colour flashing between active and expiring colours when an aura is past its expiring threshold, and the flash persisting while out of range. (PR #74 by Krathe) - -## [4.3.8] - 2026-05-12 - -### New Features - -* **Reset Page button** — every settings page that has a Sync/Copy button now also has a red **Reset Page** button to the left of them. Clicking it resets only that page's settings on the current mode (Party or Raid) back to their built-in defaults, with a confirmation popup. Aura Designer and Click Casting have their own reset systems and are not part of this. -* **Sync / Copy / Reset trio added to Visibility, Group Labels, Pinned Frames, Integrations, and Global Fonts pages** — these pages were previously missing the Sync-with-Raid/Party and Copy-to-Raid/Party buttons; they now match the rest of the addon's settings pages. -* **Reset Page button added to the Aura Blacklist page** — clicking it clears all of your custom blacklist toggles, with a confirmation popup. Sync/Copy doesn't apply here since the blacklist is shared across both modes. - -### Improvements - -* Reset, Copy, and Sync confirmation popups now use the addon's themed popup style. -* (In-game Changelog) Renamed the "Back" button on the changelog overlay to "Close" for clarity. (PR #58 by Krathe) -* Removed the ElvUI compatibility popup. DandersFrames no longer relies on Blizzard's party/raid frames, so ElvUI disabling them is harmless. -* Info and warning banners across all settings pages now have consistent styling with tone-coloured icons (info, warning, caution). (PR #57 by Krathe) -* (Aura Designer) Reworked sound indicator: Missing Trigger and Expire Alert can now be toggled independently, with separate loop intervals and a new Play Once option. (PR #54 by Krathe) -* (Aura Designer) Tint mode for health bar indicators now updates live when switching from replace mode, without needing to toggle the aura. (PR #62 by Krathe) - -### Bug Fixes - -* (Arena/Solo Shuffle) Fix teammate frames not updating between rounds when FrameSort is active. (PR #50 by Krathe) -* (Arena/Solo Shuffle) Fix frame ordering changing between Solo Shuffle rounds when FrameSort is active. (PR #73 by Krathe) -* (Aura Designer) Fix new indicators briefly appearing above the minimap until the strata setting was toggled. (PR #51 by Krathe) -* (Pinned Frames) Fix auras not showing on pinned frames when the unit isn't on your main party or raid frames. (PR #52 by Krathe) -* (Aura Designer) Fix indicators not firing on the first aura application after joining a group or entering a new zone. (PR #53 by Krathe) -* (Aura Designer) Fix sound expire alert not re-triggering after a buff is refreshed and decays again. (PR #54 by Krathe) -* (Targeted List) Fix self-target colour overlay covering the text, sticking on the wrong bar, and snapping off instead of fading. (PR #55 by Krathe) -* (Aura Filters) Info banner text now wraps correctly at narrow window widths instead of overflowing the banner. (PR #57 by Krathe) -* (Aura Designer) Fix health bar tint indicator showing the wrong colour on newly-joined party members out of combat. (PR #62 by Krathe) -* (Aura Designer) Fix health bar tint indicators not fading when out of range with element-specific OOR enabled. (PR #60 by Krathe) -* (Dispel Overlay) Fix absorb shields being hidden behind the dispel gradient on live frames. (PR #65 by Krathe) -* (Dispel Overlay) Fix overlay leaking onto live frames after adjusting settings while in test mode. (PR #66 by Krathe) -* (Dispel Overlay) Fix the gradient opacity and intensity sliders not previewing live on group frames. (PR #65 by Krathe) -* (Personal Targeted Spells) Fix the move-mode highlight box not aligning with where icons actually appear. The saved position is now the visual centre of the icon block, with a one-time migration to preserve existing placements. (PR #69 by Krathe) -* (Aura Blacklist) Fix wrong icon shown next to Skyfury in the Shaman buff blacklist. (PR #70 by Krathe) -* (Pet Frames) Fix grouped pet container misalignment when anchored to the right of the party. Users with custom Offset X on the right anchor may need to retune the slider — positive now means "more gap", matching the left anchor. (PR #71 by Krathe) -* (Pet Frames) Fix Lua error spam from pet name updates in delves and other instanced content. (PR #72 by Krathe) -* (Aura Designer) Fix Global Defaults changes not applying to live frames without a /reload. -* (Aura Designer) Fix the Reset All Aura Configs button not clearing indicators from live frames until /reload. -* (Aura Designer) Fix Color Duration by Time not transitioning live as a buff ticks down on icon, square, and bar indicators. - -## [4.3.7] - 2026-05-07 - -### Bug Fixes - -* (Aura Designer) Fix bar fill colour alpha having no effect. The alpha channel of the fill colour is now applied to bar indicators in live frames and updates immediately when changed in settings. (PR #49 by Krathe) -* (Aura Designer) Fix several settings — fill colour, border, duration text, expiring colour, Colour Bar by Duration — not applying to live frames without a /reload. (PR #45 by Krathe) -* (Aura Designer) Fix bar Colour by Duration and Expiring Colour Override resetting to grey on the second and subsequent casts of an aura. (PR #47 by Krathe) -* (Aura Designer) Fix health bar indicators briefly animating from empty when an aura is applied with Smooth Bars enabled. (PR #48 by Krathe) -* (Defensive Icons) Fix duration text "Color by Time Remaining" not updating once an aura is applied. The colour now transitions green → yellow → orange → red as the timer ticks down. The Duration Color and Color by Time Remaining settings have also moved into the Duration Text section where they belong. (PR #46 by Krathe) -* (AFK Icon) Fix AFK timer not incrementing in raids and Mythic+. The timer now ticks correctly in all group types. (PR #41 by Krathe) -* Removed the "Only Dispellable Debuffs" setting from the Debuffs page. The setting no longer affected our display since 12.0.5 and has been removed. (PR #42 by Krathe) -* (Raid Frames) Fix raid frames jumping upward when group composition changes with Groups Grow From set to Center. Previously the visible frames could shoot off the top of the screen each time a player joined a new group, requiring a Groups Grow From toggle to recover. -* (Test Mode) Fix raid test mode showing the wrong layout when Players Grow From is set to End. Test mode now mirrors what live raid frames look like. -* (Raid Frames) Fix raid groups flipping position on every GUI click when Players Grow From and Groups Grow From are both set to End. The combined setting now produces a stable layout that matches between settings panel and live frames. -* (Auto Profiles) Fix raid test mode frames landing in the wrong place when entering or exiting an auto-layout override. Test mode now repositions correctly without needing to lock and unlock the frames. -* (Status Icons) Fix duplicate summon and resurrection icons appearing on party and raid frames. Only one set of icons now renders. -* (Raid Frames) Fix Group Display Order, My Group First, and group visibility checkboxes not updating live raid frames. Player frames now reposition immediately instead of needing a /reload. - -## [4.3.6] - 2026-04-30 - -### Improvements - -* (Click Casting) Improved mouseover detection on hovered unit frames after a Blizzard API fix. -* (Click Casting) The conflict popup for Clique and Clicked now also appears when switching to a click-cast profile that turns click casting on. -* (Boss Debuffs) Updated the info banner to clarify that boss debuffs trigger dispel overlays in Hybrid or Blizzard mode. -* (Boss Debuffs) Added an Inset slider for the Blizzard dispel overlay so the gradient can extend past or shrink inside the frame edges. -* (Boss Debuffs) Replaced separate Icon Width and Icon Height sliders with a single Icon Size slider. Existing settings carry over to the larger of your two old values. -* (Boss Debuffs) Added an Open Edit Mode button to the Blizzard Overlay settings so you can preview the Blizzard dispel overlay live. - -### Bug Fixes - -* (Pet Frames) Fix Lua error spam during boss pulls when a pet is in the group. -* (Status Icons) Fix Lua error spam when the Status Icon font outline is set to "None". -* (Update Notification) Fix "You aren't in a party." and "You aren't in a raid." chat spam in LFG dungeons, LFR, scenarios, and battlegrounds. -* (Boss Debuffs) Fix icons sometimes staying invisible after a transient registration hiccup until you fully reload. -* (Boss Debuffs) Fix icons not rendering at certain size combinations. -* (Boss Debuffs) Skip private aura registration on pet frames — pets can't receive boss debuffs. -* (Click Casting) Fix target click-cast bindings on DandersFrames hitting the /target name range limit. -* (Boss Debuffs) Surface Blizzard private aura API errors instead of silently swallowing them. -* (Boss Debuffs) Fix icons sometimes rendering behind the unit frame after a re-register, even with frame level raised. -* (Aura Designer) Reduce GUI lag when opening Aura Designer with many configured effects. -* (Dispel Overlay) Fix dispel type icons not fading when a unit goes out of range with element-specific alpha enabled. - -## [4.3.5] - 2026-04-26 - -### Improvements - -* (Dispel Overlay) Added a Frame Strata dropdown and Frame Level slider to the Blizzard overlay settings. Raise them if the overlay gets hidden behind frame text on short/wide frames. -* (Boss Debuffs) Added a Frame Strata dropdown next to Frame Level. Default is now HIGH so private aura icons always render above frame text and borders, including for users with small icon sizes. Lower it via the dropdown if you preferred the old behaviour. - -### Bug Fixes - -* (Tooltips) Fix aura tooltips being overwritten by the unit tooltip when hovering buffs and debuffs - -## [4.3.4] - 2026-04-24 - -### Changes - -* (Dispel Overlay) DandersFrames-only mode users have been switched to Hybrid mode for this update — Hybrid covers boss debuffs that DandersFrames mode missed. You can switch back under Settings > Dispel Overlay > Overlay Source if you prefer. - -### Bug Fixes - -* (Buff Bar) Fix buff icons sometimes getting stuck on the bar until reload after a unit went out of range and back -* (Aura Designer) Fix tracked auras occasionally not being deduped from the buff bar after a unit returns from out of range -* (Private Aura Dispel Overlay) Overlay now stays on the correct player when roster or sort changes move players between slots, including mid-combat -* (Range) Fix Lua error spam from range checks during timewalking dungeons -* (Update Notification) Fix remaining "You aren't in a party." chat spam in delves and follower dungeons -* (Boss Debuffs) Fix boss debuff icons occasionally rendering behind the unit frame on some group members -* (Tooltips) Fix buff/debuff tooltip flickering with the unit tooltip when hovering an aura icon after entering the frame body - -## [4.3.3] - 2026-04-21 - -### New Features - -* **Pinned Frames in Test Mode** — Test Mode now fills your enabled pinned sets with fake units so you can design the layout without being in a group. Boss sets show friendly-NPC test units (Fiery Treant, Charred Bramble, etc.); player-mode sets show party/raid test units. A new **Test Count** slider in the Pinned Frames settings chooses how many frames appear (1–8 boss, 1–10 player). Test frames are non-secure mock frames that look identical to live frames — your real pinned setup isn't touched. Raid test mode only shows raid-profile sets, party test mode only shows party-profile sets, and the other mode's frames are never affected. -* **Dispel Overlay redesign** — Private Aura Dispel Overlay settings have moved to the Dispel Overlay tab under a new **Overlay Source** dropdown with four modes: - * **Hybrid** — DandersFrames for normal dispels, Blizzard for boss debuffs (recommended) - * **DandersFrames** — full customisation, does not cover boss debuffs - * **Blizzard** — covers both normal debuffs and boss debuffs, limited customisation - * **Off** — disabled - - In Hybrid mode the two overlays no longer double up; DandersFrames handles normal dispels while Blizzard picks up boss debuffs only. "Show Overlay For" (Dispellable By Me / All Dispellable) is now a single unified setting shared by both overlays. - -### Improvements - -* (Friendly Boss NPC Frames) Frames no longer collapse upward when a boss dies — surviving frames hold their position, and new bosses fill the next free slot. Slot assignments reset when combat ends -* (Private Aura Dispel Overlay) Overlay no longer covers the frame border, text, and icons -* (Private Aura Dispel Overlay) Added an Alpha slider to dim the overlay -* (Dispel Overlay) Section headers now show tags indicating which dispel types each section covers under the current Overlay Source mode -* (Dispel Overlay) Reduced CPU overhead in Blizzard and Off modes during combat - -### Changes - -* (Private Aura Dispel Overlay) Removed the "Show Dispel Icons" toggle — the top-right icons cannot be hidden separately from the overlay, so the option had no effect - -### Bug Fixes - -* (Aura Designer) Fix constant tooltip Lua error spam during raid encounters with secret auras -* (Range) Fix error spam when range fading is active -* (Update Notification) Fix "You aren't in a party." chat spam in NPC follower dungeons and delves -* (Friendly Boss NPC Frames) Aura Designer indicators now apply correctly when a boss slot swaps to a new NPC mid-encounter -* (Friendly Boss NPC Frames) Out-of-range fading now works on boss frames -* (Friendly Boss NPC Frames) Fix health, power, name, absorb, heal prediction, and aura updates not applying reliably -* (Friendly Boss NPC Frames) Fix Aura Designer indicators from a previous boss lingering on a slot after it's assigned to a new NPC -* (Friendly Boss NPC Frames) Reduce race conditions where a boss frame intermittently fails to appear on spawn -* (Friendly Boss NPC Frames) Fix health and Aura Designer indicators not updating when a boss slot silently swaps to a different NPC mid-encounter -* (Pinned Frames) Fix stale background, border, and label from the real pinned container showing behind test frames when Test Mode matches the current group mode -* (Pinned Frames) The set label now appears above test frames in all cases, including cross-mode previews (e.g. raid test mode while in a party) -* (Targeted List) Cast bar now snaps to full yellow on interrupt instead of continuing to fill - -## [4.3.2] - 2026-04-21 - -### New Features - -* **Friendly Boss NPC Frames** — Pinned frame sets now have a Frame Type setting. Switch a set to "Friendly Boss NPCs" to display healable friendly boss units (boss1–boss8) instead of group members. Useful for encounters where friendly adds need healing. All layout, positioning, click-casting, buffs, debuffs, Aura Designer indicators, and out-of-range fading work the same as player-mode pinned sets. Visible frames compact to the set's anchor so there are no empty slots when only some boss units are friendly — even as bosses appear and die during combat. -* **Update notification** — if another DandersFrames user in your group or guild is running a newer stable version, you'll see a one-time chat message on login. Can be disabled in General > Settings > Notifications. - -### Improvements - -* **Pinned Frames** — movers are now color-coded per mode (orange for raid, purple-blue for party) so it's obvious which mode's position you're editing -* **Pinned Frames** — opening a pinned-frames page for the inactive mode (e.g. Raid settings while you're solo or in a party) now shows a preview container for that mode's frames so you can reposition them without joining a group -* (Aura Filters) Added an Aura Blacklist pointer section with a link to the dedicated Aura Blacklist tab, making it easier to find spell-specific exclusions from the filters page - -### Bug Fixes - -* Fix DPS jumping order mid-dungeon when "Separate Melee & Ranged DPS" is enabled -* (Click-Casting) Fix "In Combat Only" and "Out of Combat Only" conditions being ignored for Target Unit and Open Menu bindings -* (Aura Designer) Fix beacon indicators being invisible if saved at an icon size smaller than the slider minimum -* (Aura Designer) Fix indicators inside Layout Groups not being draggable -* (Aura Designer) Spec-specific spells (e.g. Earthshield) now appear correctly after switching specs without needing a reload -* (Auto Profiles) Fix brief flicker to party settings when exiting the auto-profile editor before the raid override re-applies -* (Status Text) Fix "Offline" / "AFK" text lingering on a frame after the player comes back online -* (Status Text) Add "DND" status text display (previously only AFK was shown) -* (Frames) Fix the Resurrected buff icon staying on a player's frame after they've come back to life -* (Frames) Fix the summon-pending icon staying on your frame after leaving the group -* (Auras) Raid frame aura icon borders are now pixel-perfect (were slightly blurry when raid frame scale differed from UIParent) -* (Boss Debuffs) Fix boss debuff icons overlapping instead of spacing correctly when tooltips are hidden and growth direction is left/up -* (Defensives) Fix defensive cooldown icons swapping slot positions / flickering when multiple cooldowns are active -* (Defensives) Fix the second defensive cooldown icon not fading when the player is out of range or out of phase -* (Pinned Frames) Dragging the mover while viewing the inactive mode's settings no longer silently saves the new position to the active mode's profile -* (Pinned Frames) The Enable, Lock Position, and Show Label checkboxes no longer mutate the active mode's container when toggled from the inactive mode's settings -* (Pinned Frames) Fix the second pinned-frames tab being unselectable when the two sets had different Frame Types — the tab now sticks across the page rebuild -* (Pinned Frames) Boss-mode preview container now uses a single-frame placeholder (matching live behaviour when no boss is visible) instead of a four-frame-wide box -* (Aura Designer) Sound alerts now pick up live edits without toggling the alert off and on -* (Test Mode) Correct Monkbrew test unit from Mistweaver (healer) to Brewmaster (tank) -* (Frames) Fix "No secure position handler!" red error spamming chat on login/reload when raid frames are disabled - -### 12.0.5 Compatibility - -* **Private Aura Dispel Overlay** — on 12.0.5+, a new Blizzard-rendered dispel overlay for private auras replaces the old frame border overlay. Controlled from Boss Debuffs settings with options for dispel filter, gradient direction, and dispel type icons. -* Fix private aura anchors for 12.0.5 API changes - -### API - -* **OnFramesSorted callback** — External addons can subscribe to `DandersFrames.RegisterCallback(self, "OnFramesSorted", ...)` to be notified whenever party, raid, or arena frames are reshuffled. Fires once per sortType per tick (coalesced) in both combat and non-combat — covers settings changes, roster updates, role swaps, spec detection, and Blizzard's internal ASSIGNEDROLE re-sorts. Callback receives `(event, sortType)` where sortType is `"party"`, `"raid"`, or `"arena"`. Safe to call `DandersFrames_GetFrameForUnit(unit)` from inside the handler. - -## [4.3.1] - 2026-04-15 - -### New Features - -* **Disable Party or Raid frames** — new toggles in General > Settings let you fully disable either frame system. Disabled frames never load, so they use no resources. Requires a reload; the popup can also toggle Blizzard's frames on or off for you in the same reload. -* **Custom font for the settings panel** — pick a font and outline in General > Settings > Appearance. Applies instantly, no reload. -* **Addon language override** — run the addon in a different language than your WoW client (per-character). Defaults to auto-detect. - -### Improvements - -* Reorganised General > Settings into Frame Modes, Blizzard Frames, Appearance, and Language sections -* Blizzard frame toggles renamed from "Hide" to "Disable" (they fully disable, not just hide) and now appear in both Party and Raid views -* "Hide Blizzard Player Frame" moved to Display > Visibility -* Fixed Cyrillic, Korean, and Chinese characters showing as squares in various places - -### Bug Fixes - -* (Aura Designer) Holy Paladin: Holy Bulwark now triggers the same indicator as Sacred Weapon. A warning icon on the spell explains why the two can't be tracked separately. - -## [4.3.0] - 2026-04-10 - -### Improvements - -* (Auras) Force-disable Blizzard aura data source ahead of its removal in 12.0.5 — all users now use Direct API mode immediately -* (Auras) Prevent Blizzard aura source from persisting via profile imports — removed from export categories, forced to Direct on import and every login -* (Aura Filters) Add info banner clarifying that Aura Filters only affect Buff Bar and Debuff Bar, with clickable links to related pages -* (Aura Filters) Remove outdated Defensives and Dispel Detection info section -* (Aura Filters) Dispellable filter now uses a toggle switch (Dispellable By Me / All Dispellable) instead of two separate checkboxes -* (Aura Filters) Warning banner when "All Debuffs" is disabled, recommending healers keep it enabled -* (Aura Blacklist) Add notice explaining the blacklist is a curated Blizzard list -* (Aura Blacklist) Add warning icon next to Symbiotic Relationship noting caster-only blacklist limitation -* (Aura Blacklist) Increased warning and notice banner icon sizes for better visibility -* (Boss Debuffs) Add info banner noting Boss Debuffs cannot trigger Dispel Overlays - -### New Features - -* **Toggle Switch GUI element** — new reusable UI control for mutually exclusive A/B settings, with themed visuals and label highlighting -* **"New" tab badges** — gold "New" text appears on tabs and their parent category for new features, auto-hides once the tab is opened -* **Targeted List** — a new stacked cast-bar display that shows enemy casts targeting party members. Replaces the group-frame Targeted Spells icons that were broken by Blizzard's recent UnitIsUnit hotfix. Party-mode only, with draggable mover, test mode, four style presets, and full appearance controls including font, colors, icon, border, textures, arrow prefix/suffix, self-target color overlay, hide out-of-combat filter, cast-to-channel transitions, interrupted flash, and per-text-element positioning - -### Bug Fixes - -* (Tooltips) Add tooltip refresh ticker so third-party tooltip addons (e.g. RaiderIO) can respond to modifier key state changes while hovering unit frames -* (Tooltips) Add clean unit token resolver for tooltip SetUnit calls to work around Midnight 12.0 taint propagation from secure frame attributes -* (Absorb Bars) Fix absorb bars showing as floating bars when Attached or Attached+Overflow display mode is selected -* (Aura Designer) Fix indicators sometimes showing wrong settings (wrong font size, icon size, bar colors) — Configure now runs mid-combat so indicators always get correct static settings immediately -* (Aura Designer) Fix duration text using default Blizzard font/size when the cooldown FontString hasn't been created yet at configure time -* (Personal Targeted Spells) Removed the white center-line overlays from the mover frame -* (Targeted Spells) Added `UNIT_SPELLCAST_FAILED_QUIET` to the registered event list — some cancelled casts previously left dangling personal-display icons - -## [4.2.9] - 2026-04-10 - -### Improvements - -* (GUI) Modernised all scroll bars across the addon to use the new slim pill-style thumb instead of the default Blizzard scroll bars - -### Bug Fixes - -* (Dispel Overlay) Fix "All Dispellable" mode not showing the dispel overlay when using the Blizzard aura data source - -## [4.2.8] - 2026-04-08 - -### New Features - -* (Profiler) Major rework of the function profiler with new tabs for per-event CPU cost and per-frame OnUpdate handlers -* (Profiler) New columns for peak calls per frame and per-call memory allocation -* (Profiler) Right-click "Print to Chat" now emits a Top 5 summary -* (Profiler) Function coverage expanded from ~30 to ~140 tracked methods - -### Performance - -* **Major raid performance pass** — total addon CPU down about **62%** and memory churn down about **93%** in a 20-player boss fight. Expect smoother frames during heavy AoE, big pulls, and dispel-heavy fights, especially on older hardware -* (Buff & Debuff Tracking) Rebuilt the core aura system so it does the work once instead of repeating the same scan 6–7 times per update. Around 99% of aura events now use the fast cached path in raid -* (Dispel Highlights) The colored outline around dispellable debuffs is now near-free — its CPU cost dropped by about 95% -* (Absorb Shields) Shield overlays update about twice as fast and no longer create throwaway data on every update -* (Animated Highlights) Marching-ants borders redraw at 30 FPS instead of 60 with no visible difference, cutting their CPU cost to about a third -* (Aura Designer) Custom indicators reuse a pool of entries instead of creating new ones every frame — noticeably lighter at raid scale -* (Aura Designer) Expiring border color animation no longer tears down and rebuilds the entire border on every tick -* (Frame Updates) Small efficiency win across every frame refresh from caching commonly used WoW APIs - -### Bug Fixes - -* (Profiler) Fix taint errors from the profiler incorrectly wrapping Blizzard's secure compact raid frame handlers. The profiler now only instruments DandersFrames-owned frames - -### Technical Notes - -For the curious — measured in a 5m 38s raid boss fight (493,901 calls profiled): - -* **Total addon CPU:** ~114 ms/sec → ~43 ms/sec (**−62%**) -* **Memory churn (top sources):** ~12 MB/sec → < 1 MB/sec (**−93%**) -* **Aura cache hit rate:** 99.6% (36,204 of 36,360 events skip the full scan) -* **Dispel overlay CPU share:** 16.4% → 0.8% (**−95%**) -* **Absorb update per call:** ~111 µs / ~80 B → 38.4 µs / 0 B (**−65% CPU, zero allocation**) -* **Aura tracking:** Now uses the `UNIT_AURA updateInfo` payload with a shared `DF.AuraCache` keyed by aura instance ID. Both Aura Designer and Dispel overlay read from the same cache instead of re-querying `C_UnitAuras` -* **Aura Designer indicator entries** are pooled (max 64 reused), and instance keys are cached in a two-level lookup table -* **Dispel overlay** caches its layout state and short-circuits `ApplyOverlayLayout` when nothing relevant changed — same pattern was then applied to absorb shields -* **Update.lua** caches `UnitHealth`, `UnitPower`, `InCombatLockdown` and 9 other Unit\* APIs as file-scope locals to skip global lookups in the hot path - -## [4.2.7] - 2026-04-07 - -### New Features - -* (Auras) Added `Player Dispellable` and `All Dispellable` debuff filters to Direct API mode - -### Improvements - -* (Locales) Locale warnings now silent by default - -### Performance - -* (Auras) Reduced wasted aura processing in raid combat -* (Range) Reduced wasted range update events in busy zones - -### Bug Fixes - -* (Auras) Fix "Compound unit tokens are not allowed" error spam in raid and arena -* (Internal) Removed two non-unit events from per-frame filter list - -## [4.2.6] - 2026-04-07 - -### Bug Fixes - -* (Aura Designer) Fix error spam from secret aura tracking -* (Locales) Fix locale errors showing to users -* (Defensive Icon) Fix defensive auras showing in both the buff bar and the defensive icon at the same time - -## [4.2.5] - 2026-04-07 - -### Improvements - -* (Debug Console) Redesigned page with collapsible sections and a wider log viewer -* (Defensive Icon) Now shows multiple big defensives at once and works consistently across both aura source modes - -### WoW API Changes - -* (Targeted Spells) Group-frame targeted spell icons disabled — a recent WoW change prevents addons from knowing which party member an enemy is targeting. Personal Targeted Spells still works, and a replacement display is in development -* (Auras) Auto-switch to Direct API mode if the upcoming WoW 12.0.5 patch removes the Blizzard aura source. A popup will explain the change if it triggers -* (Aura Designer) Temporarily disabled Symbiotic Relationship target tracking due to a WoW API issue. Restoration Druids will lose this indicator until it can be reworked - -### Bug Fixes - -* (Dispel Overlay) Fix "All Dispellable" mode not firing when the dispellable debuff was filtered out of the icon display -* (Debug Console) Fix log viewer crashing when certain protected values were logged -* (Auras) Fix a load-time error when Direct API mode was enabled - -### Diagnostics - -* (Raid Frames) Added logging to help track down the "raid frames jump on roster change" bug. If affected, open `/df console`, enable the **RAIDPOS**, **LAYOUT**, **ROSTER**, and **FRAMESORT** categories, reproduce, then send the log with your bug report. - -## [4.2.4] - 2026-04-05 - -### Bug Fixes - -* (Dispel Overlay) Fix health bars appearing darkened/black when a unit had any non-dispellable debuff in "All Dispellable" mode with the gradient darken option enabled - -## [4.2.3] - 2026-04-05 - -### Bug Fixes - -* (Localization) Add missing "Pull Timer" locale string causing errors on load - -## [4.2.2] - 2026-04-05 - -### Improvements - -* (Auras) Increase all buff, debuff, boss debuff, and missing buff offset slider ranges from ±20–100 to ±150 for consistency with Aura Designer - -### Bug Fixes - -* (Click Casting) Fix click casting not working on pinned frames -* (Dispel Overlay) Fix "All Dispellable" mode only showing debuffs the player can personally dispel — now correctly highlights any dispellable debuff (Magic, Curse, Disease, Poison) regardless of class -* (Dispel Overlay) Fix swapped dropdown labels for "All Dispellable" and "Dispellable By Me" options - -## [4.2.1] - 2026-04-04 - -### Bug Fixes - -* (Localization) Fix Spanish locale file containing a CurseForge API error instead of translation data - -## [4.2.0] - 2026-04-04 - -### New Features - -* (Resource Bar) Add "Use Class Color" option for resource bars — colors power bars by class instead of power type (thanks **sKullsen**) -* (Localization) Add full localization infrastructure using AceLocale-3.0 and CurseForge translation system — community translators can now contribute translations via the CurseForge web UI without touching code -* (Localization) Add locale stubs for 11 languages: English, German, Spanish (EU/LATAM), French, Italian, Korean, Portuguese (BR), Russian, Chinese (Simplified/Traditional) - -### Improvements - -* (Frames) Add tooltip to resurrection icon showing cast status (green = incoming, yellow = pending accept) -* (Frames) Status icons (summon, AFK, phased, resurrection) now stay fully visible when unit is out of range or dead - -### Bug Fixes - -* (Raid Frames) Fix groups overlapping after auto-profile switch when layout direction and spacing are unchanged between profiles -* (Raid Frames) Fix CENTER-aligned groups landing in wrong positions when the first person joins a previously empty group -* (Fonts) Fix client crash (ACCESS_VIOLATION) when SetFontObject receives an uninitialized font family during early login -* (Auto Layouts) Fix frames using wrong positions or settings when switching between grouped and flat raid layouts -* (Auto Layouts) Fix double frame refresh when switching between auto-profiles -* (Auto Layouts) Fix race condition between auto-profile evaluation and roster update processing -* (Auto Layouts) Fix flat raid fast path not reapplying layout settings when spacing or anchors change -* (Auto Layouts) Fix grouped headers staying empty after switching from flat to grouped mode on instance entry -* (Auto Layouts) Fix raid container drifting to wrong position after group sorting due to CENTER anchor resize -* (Auto Layouts) Fix profile switch reading stale overlay settings during refresh -* (Auto Layouts) Fix flat raid container not resizing immediately after layout settings change -* (Auto Layouts) Add defensive refresh after auto-profile deactivation to prevent partially-configured frame state -* (Health Text) Fix Abbreviate (K/M) not working in Deficit mode outside of Test Mode (thanks **andybergon**) -* (Settings) Fix Health Bar section sync accidentally overwriting Health Text settings due to overly broad prefix matching -* (Resource Bar) Remove stale type guards that could prevent the resource bar from displaying power values -* (Missing Buffs) Fix missing buff indicators not fading when a unit is dead or offline -* (Aura Designer) Fix icon border appearing asymmetric at certain sizes by snapping to pixel boundaries -* (Aura Designer) Fix right panel sizing breaking when switching between Party and Raid mode on narrow windows -* (Aura Designer) Fix sound alert preview failing when "None" is selected or LSM returns a non-path value -* (Test Mode) Fix heal prediction animations showing inconsistent direction after importing a profile -* (Position Panel) Fix "Hide Drag Overlay" preference resetting every time the mover is unlocked - -## [4.1.11] - 2026-04-01 - -### Bug Fixes - -* (Click-Casting) Fix right-click menu not working on Blizzard frames when a right-click spell binding is set to DandersFrames only - -## [4.1.10] - 2026-03-31 - -### New Features - -* (API) Add layout config endpoints — `DandersFrames_GetPartyConfig()` and `DandersFrames_GetRaidConfig()` return frame dimensions, scale, spacing, and layout settings for external addon integration -* (Boss Debuffs) Add Text Scale slider for timer and stack count text -* (Aura Designer) Add expire sound alert — plays a sound when the longest active buff duration drops below a configurable threshold -* (Aura Designer) Add collapsible settings groups — indicator settings sections can be collapsed/expanded by clicking the header, with state persisted across sessions -* (Aura Designer) Add bottom collapse bar to expanded indicator cards and settings groups for quick access - -### Improvements - -* (Aura Designer) Add warning messages when Preview Sound has no sound file selected or the file fails to play - -### Bug Fixes - -* (Frames) Fix IteratePinnedFrames error on roster update caused by function used before declaration -* (Frames) Fix frames staying stuck as offline after a player reconnects -* (Grouped Raids) Fix groups briefly overlapping when someone joins the raid -* (Boss Debuffs) Fix overlay border showing tooltips when it shouldn't -* (Aura Designer) Fix Adapter nil error on login when spec is set to auto -* (Click-Casting) Fix deleted click bindings being silently restored -* (Frames) Fix ADDON_ACTION_BLOCKED error when entering combat while dragging raid frames -* (Frames) Fix role icon alpha setting not applying in test mode -* (Aura Designer) Fix indicator borders rendering below the unit frame border -* (Frames) Fix resource bar not showing in delves due to unassigned role -* (Grouped Raids) Fix raid frame anchor shifting position when changing specs -* (Frames) Fix summon icon staying visible after a player leaves the group -* (Boss Debuffs) Fix hide tooltip setting hiding boss debuff icons in test mode -* (Blizzard Frames) Fix side menu flickering when set to hidden - -## [4.1.9] - 2026-03-27 - -### New Features - -* (Boss Debuffs) **Frame Border Overlay** — shows a border around the entire unit frame when boss debuffs are active, with auto-fit sizing and adjustable settings -* (Boss Debuffs) **Overlay Setup Wizard** — guided setup with image previews when enabling the overlay for the first time, including a warning about visual quirks -* (Boss Debuffs) **Hide Tooltip** option — prevents the tooltip from appearing when hovering over boss debuff icons -* (Boss Debuffs) **Test Mode Overlay Preview** — preview the overlay border in test mode without needing to be in combat - -### Changes - -* (Boss Debuffs) Simplified private aura system — cleaner single-anchor approach, removed unused settings -* (Boss Debuffs) Overlay icon ratio slider now goes up to 15 to support very wide frames - -### Bug Fixes - -* (Auras) Fix auras not showing after switching profiles with different data source settings -* (Aura Designer) Fix indicator icons blocking click-casting in combat -* (Grouped Raids) Fix groups growing from the wrong direction after changing settings -* (Grouped Raids) Fix group display order resetting when changing raid settings -* (Grouped Raids) Fix group labels misaligning after switching layout direction -* (Flat Raids) Fix hidden groups sometimes showing frames when sorting is active -* (Flat Raids) Fix a group disappearing after roster changes during combat - -### Performance - -* (Aura Designer) Reduced per-event work — static properties are now set once on config change instead of every aura event - -## [4.1.8] - 2026-03-26 - -### New Features - -* (Auras) Add Aura Filter Setup Wizard — guided setup to help configure aura data source and filter options. Runs automatically on first login after update, or manually via the Aura Filters settings tab - -## [4.1.7] - 2026-03-25 - -### Bug Fixes - -* (Auras) Fix Blizzard data source showing no debuffs — Blizzard moved aura data from frame arrays to container objects in the latest update, updated reader to use new Iterate API -* (Auras) Fix dispel overlay not working in Blizzard data source — use Direct API dispel filter (IsAuraFilteredOutByInstanceID) for secret-safe dispel detection since old dispelDebuffFrames no longer populated - -### Changes - -* (Auras) Switch default aura data source to Direct API for all new and existing profiles — provides full control over buff/debuff filtering. Users can switch back to Blizzard mode in settings if preferred -* (Auras) Update default Direct API filters: show all debuffs, sort buffs and debuffs by time remaining - -## [4.1.6] - 2026-03-25 - -### Bug Fixes - -* (Growth) Fix nil wrap error when growth direction value has no underscore separator -* (Growth) Add safety fallback for nil wrap in growth direction composer - -## [4.1.5] - 2026-03-24 - -### Bug Fixes - -* (Grouped Raids) Fix hidden groups sometimes showing frames when players join or are moved into them — hidden group headers are now fully neutralized (attributes cleared) so they can never claim or display units -* (Boss Debuffs) Fix private auras showing on wrong players after sorting or roster changes — restore reanchor system with combat lockdown guards so anchors rebind to the correct unit token -* (Targeted Spells) Stagger icon pool creation for raid frames to prevent script-ran-too-long errors when 40 frames initialise simultaneously -* (Auras) Use SetCooldownFromDurationObject for secret-safe aura cooldowns -* (Auras) Add issecretvalue local cache to Icons.lua and DebugAuras.lua - -## [4.1.4] - 2026-03-23 - -### New Features - -* **Frame Scale** — new slider in Layout settings to scale party and raid frames (0.5x–2.0x). Movers, snap-to-grid, and drag all work correctly at any scale. Scale is per-profile and applies to containers, movers, and test frames. -* (Pinned Frames) **Auto-Update by Role** — when auto-add role filters are active (tanks, healers, DPS), players whose role no longer matches are automatically removed. Manually added players and offline players are never auto-removed. - -### Bug Fixes - -* (Grouped Raids) Fix empty groups overlapping populated groups — empty groups were being positioned at their natural grid slot instead of being skipped, causing overlap when groups compact -* (Grouped Raids) Fix groups sometimes overlapping on roster change — position handler now re-fires on every roster update to stay in sync with WoW's internal child re-sorting -* (Flat Raids) Fix raid anchor moving when respeccing or dying — grouped-mode positioning was resizing the shared container when flat mode was active -* (Flat Raids) Fix frames overlapping with grouped headers when auto layout switches from grouped to flat mode -* (Pinned Frames) Fix frames drifting towards bottom-left when changing scale -* (Pinned Frames) Fix drag speed mismatch at non-1.0 scale — frames now track the cursor 1:1 at any scale - -## [4.1.3] - 2026-03-17 - -### New Features - -* (Aura Designer) **Show When Missing** — per-indicator toggle that inverts visibility: shows the indicator when the aura is absent, hides when present. Supports all indicator types except bars. Icons support a "Desaturate When Missing" sub-option. -* (Aura Designer) **Show When Missing + Expiring** — when both are enabled, the indicator stays hidden while the buff is active, appears during the expiring window, then shows with normal appearance once the buff drops off -* (Auras) **Growth Direction Control** — replaced the single growth dropdown with a three-part control (Orientation, Wrap, Direction) for clearer configuration -* (Aura Designer) **Sound Alerts** — per-indicator sound alerts that play when an aura appears, expires, or is missing. Supports all LibSharedMedia sounds, adjustable volume, loop/one-shot modes, and a global "Mute All Sound Alerts" toggle in the Aura Designer banner. Includes a searchable sound dropdown picker. -* (Sorting) **[Experimental] FrameSort Addon Integration** — added support for the FrameSort addon. When enabled in General > Sorting, FrameSort controls frame ordering for party, raid (flat and grouped), and arena frames. Requires the FrameSort addon to be installed separately. - -### Bug Fixes - -* (Raid Frames) **Major fix** for raid frames jumping/shifting position when players join, leave, or when loading into LFR/BGs — completely reworked the reposition pipeline to batch all updates into a single authoritative reposition, with a settling debounce for instance loading -* (Flat Raid Frames) Fixed flat raid frames flickering between party and raid settings during group transitions -* (Flat Raid Frames) Fixed flat raid frame positioning breaking after layout or roster changes -* (Position) Fixed mover handles for both party and raid staying visible after switching group type -* (Auto Layouts) Fixed several issues with switching between flat and grouped layouts — duplicate frames, hidden groups reappearing after combat, and layout not updating after mid-fight settings changes -* (Aura Designer) Fixed grouped layout preview not rendering correctly after the growth direction overhaul — indicators were stacking on top of each other instead of spreading out -* (Aura Designer) Fixed custom border indicators not showing on the frame preview -* (Aura Designer) Fixed indicators appearing on disabled pinned frames -* (Aura Designer) Fixed several Show When Missing visual issues — out-of-range alpha, transparent frames, stale duration text, pulsate animation not stopping, and indicators not appearing in test mode -* (Sound Alerts) Fixed sound engine not finding raid frames when using flat layout -* (Sound Alerts) Sound-only auras now correctly tracked for buff bar dedup -* (Sorting) Fixed secret string taint in cross-realm name caching - -### Improvements - -* (Debug Console) Added comprehensive debug logging across roster updates, header visibility, flat raid operations, frame positioning, and frame layout — helps diagnose frame issues in the field - -## [4.1.2] - 2026-03-16 - -### New Features - -* (Health Text) **Hide % Symbol** — new checkbox to remove the percent sign from health percentage text -* (Pinned Frames) **Growth direction anchoring** — Frame Growth and Column Growth now support Start, Center, and End options, controlling which edge stays fixed as frames are added (e.g. "Start" grows rightward/downward, "End" grows leftward/upward) -* (Pinned Frames) **Reset Position button** — resets a pinned frame set to the center of the screen if it gets lost off-screen - -> **Note:** Pinned frame positions may have shifted slightly due to the new anchoring system. Use the Reset Position button or reposition frames if needed. - -### Bug Fixes - -* (Auras) Fixed buff/debuff borders staying visible even when disabled — operator precedence bug caused the buff border check to fire regardless of aura type -* (Aura Designer) Fixed stack count text bleeding onto adjacent icons when auras reorder in a layout group -* (Defensive Icons) Fixed 2nd+ defensive bar icons always showing tooltip and ignoring tooltip settings, anchor position, and click-through configuration -* (Resource Bar) Fixed resource bar being 2px too wide when "Match Width" is enabled and a frame border is active -* (Status Icons) Fixed leader icon not hiding in combat when "Hide in Combat" is enabled -* (Pinned Frames) Fixed error when OnDragStop fires without a matching OnDragStart on pinned frame movers - -## [4.1.1] - 2026-03-15 - -### Bug Fixes - -* (Position) Lowered permanent mover frame strata from HIGH to MEDIUM so it no longer covers other UI elements -* (Defensive Icons) Fixed double-scaled positioning offsets causing defensive icons to stack vertically instead of horizontally -* (Defensive Icons) Reduced raid frame defensive icon defaults (size 20, scale 1.0, max 3) to fit narrower raid frames -* (Pinned Frames) Fixed aura designer indicators (borders, defensives, dispels) leaking onto disabled pinned frame sets -* (Aura Designer) Fixed border indicator pandemic state using the regular border alpha instead of the configured expiring alpha -* (Aura Designer) Declassified Beacon of Virtue as non-secret — spell ID 200025 is on Blizzard's whitelist and readable via standard API - -## [4.1.0] - 2026-03-14 - -### New Features - -* (Position) **Permanent Mover handle** — a small always-visible drag handle on frames for repositioning without unlocking, with customizable position, size, offset, colors, show-on-hover with fade animation, hide-in-combat option, and red combat indicator -* (Position) **Permanent Mover quick actions** — left-click, right-click, shift+left-click, and shift+right-click can be bound to 13 preset actions including open settings, quick switch profile/click-cast profile, cycle profiles, toggle test mode, unlock frames, toggle solo mode, ready check, pull timer, reset position, and reload UI -* (Position) **Permanent Mover attach to unit** — handle can be attached to the container, first visible unit, or last visible unit so it follows the group size -* (Position) **Hide drag overlay** checkbox in the unlock panel to hide the blue drag area while keeping frames draggable -* (Dispel Overlay) **Color Name Text** — optional checkbox to color the unit's name text with the dispel type color when a dispellable debuff is present -* (Aura Designer) **Expiring pulsate for icon, square, and health bar indicators** — borders and fills can now pulse when an aura is about to expire -* (Aura Designer) **Expiring whole alpha pulse** — entire icon/square pulses its alpha when expiring -* (Aura Designer) **Expiring bounce animation** — icon/square bounces up and down when expiring -* (Aura Designer) **Hide duration text above threshold** — duration text can be hidden when the remaining time is above a configurable seconds threshold (icon, square, and bar types) -* (Aura Designer) **Expiring threshold in seconds** — expiring indicators can now trigger based on remaining seconds as well as remaining percentage -* (Aura Designer) **Trigger operator (ANY / ALL)** — indicators with multiple trigger spells can now require all triggers to be active (AND mode) or just one (OR mode, default) -* (Aura Designer) **Duration priority (Highest / Lowest)** — expiring indicators on multi-trigger spells can track the highest or lowest remaining duration buff -* (Aura Designer) **Custom border mode** — border indicators can now use an independent overlay per aura, so multiple border indicators can be visible at the same time -* (Aura Designer) **Settings grouped in containers** — all indicator settings panels and global defaults are now organized with bordered section containers -* (Aura Designer) **Earthliving Weapon** added as a trackable Restoration Shaman aura -* (Aura Designer) **Sense Power** added as a trackable Augmentation Evoker secret aura -* (Aura Designer) **Ebon Might self-buff tracking** — Augmentation Evoker's caster self-buff (395296) is now tracked on the player via fingerprint disambiguation, with correct tooltip and buff bar dedup -* (Aura Designer) **Symbiotic Relationship linked aura system** — Restoration Druid's caster buff is detected on the player and mirrored as an indicator onto the target's frame, with OOC target resolution, tooltip-based fallback, recast detection, and buff bar dedup -* (Aura Designer) **Ancestral Vigor** added as a trackable Restoration Shaman aura -* (Aura Blacklist) **Expanded blacklist coverage** — added Rogue poisons, Shaman weapon imbuements, Blessing of the Bronze (all class variants), Paladin rites, Mage Icicles, Hunter Tip of the Spear, and Shaman Reincarnation -* (Debug) **Script Runner** — multiline Lua script input in the debug console with persistent text across sessions - -### Bug Fixes - -* (Position) Fixed nudge buttons causing the blue drag area to vanish -* (Auras) **Fixed taint errors from secret value comparisons** — duration hide, expiring indicators, and color curves now correctly pipe secret values through secret-aware APIs only - -## [4.0.16] - 2026-03-11 - -### Bug Fixes - -* (Click Casting) **Fixed binding tooltip vanishing when pressing modifier keys** — modifier format mismatch caused all bindings to be filtered out -* (Pet Frames) Fixed taint error from secret boolean in pet range checking -* (Fading) **Fixed name and health text alpha resetting to 1.0** on zone change, combat res, vehicle exit, and test mode exit -* (Aura Designer) **Fixed secret auras not appearing immediately on cast in combat** — inline fingerprint matching eliminates race condition between detection and rendering -* (Aura Designer) Fixed Verdant Embrace tooltip incorrectly showing Upheaval - -### New Features - -* (Aura Designer) **Secret aura tracking** — tracks auras that WoW hides behind secret spell IDs using signature-based fingerprinting (credit to Harrek for the technique and aura data from Advanced Raid Frames) -* (Aura Blacklist) **Combat / out-of-combat controls** — per-spell checkboxes to blacklist auras only in combat, only out of combat, or both -* (Aura Blacklist) Redesigned blacklist UI as a single unified spell list with inline toggle and checkboxes - -### New Trackable Auras (Aura Designer) - -* **Preservation Evoker:** Time Dilation, Rewind, Verdant Embrace -* **Restoration Druid:** Ironbark -* **Discipline Priest:** Pain Suppression, Power Infusion -* **Holy Priest:** Guardian Spirit, Power Infusion -* **Mistweaver Monk:** Life Cocoon, Strength of the Black Ox -* **Restoration Shaman:** Hydrobubble -* **Holy Paladin:** Blessing of Protection, Holy Armaments, Blessing of Sacrifice, Blessing of Freedom, Dawnlight, Beacon of Virtue - -### Improvements - -* (Aura Designer) Spell cards now show WoW spell tooltips on hover -* (Aura Designer) Secret auras shown in a distinct section with visual styling to differentiate from regular auras -* (Aura Designer) Added "unsupported spec" message when viewing a non-healer spec -* (Aura Designer) **Class color border** on preview frame window showing the current spec's class -* (Aura Designer) **Class-colored spec dropdown** — each spec name colored by class for clarity -* (Aura Designer) **Customise button** on layout group members — jumps directly to that aura's effects settings -* (Aura Designer) Fixed page scrolling — only the right settings panel scrolls now, preview stays in view -* (Auras) Added **Raid In Combat** debuff filter option — matches the existing buff filter for better debuff coverage -* (Click Casting) Renamed "Mouseover" fallback to "Global" for clarity -* (Click Casting) "Does not work with action bar binds" warning now highlighted in red - -## [4.0.15] - 2026-03-10 - -### Bug Fixes - -* (Fading) **Fixed combat stutter when leaving combat** -* (Fading) **Fixed false out-of-range on units that were actually in range** -* (Fading) **Fixed everyone always showing as in-range** — re-added polling timer as a safety net alongside event-driven updates -* (Fading) **Fixed player frame being affected by out-of-range fading** -* (Aura Designer) **Fixed indicators ignoring their configured alpha** -* (Pet Frames) Fixed taint error when pet frame style changes during combat -* (Aura Blacklist) Fixed Harrier's Exhaustion not being filterable -* (Click Casting) Fixed binding tooltip showing wrong modifier -* (Aura Designer) Fixed health text showing in indicator preview when disabled - -### New Features - -* (Fading) **Hybrid range checking** — range now uses both instant events and a configurable polling timer for maximum reliability -* (Fading) **Missing health bar out-of-range alpha** — new element-specific alpha slider for the missing health (damage) portion of the health bar - -## [4.0.14] - 2026-03-08 - -### Bug Fixes - -* (Fading) **Fixed power/resource bar not fading when out of range** -* (Fading) **Fixed name text and health text not fading when out of range or dead/offline** in element-specific alpha mode -* (Fading) **Fixed debuff borders staying visible when faded** -* (Fading) Fixed defensive icons not fading when using Direct API mode with multiple defensives -* (Fading) Fixed name text flickering or staying at full alpha after switching specs -* (Fading) Fixed range checking not updating after changing talents -* (Missing Buff) Fixed missing buff indicator incorrectly showing on NPC followers in follower dungeons -* (API) Fixed external API functions not returning arena frames — `GetFrameForUnit()`, `GetAllFrames()`, and `IterateFrames()` now work correctly inside arenas -* (Side Menu) Improved hiding of Blizzard's raid/party side menu when disabled in settings -* (Raid Frames) **Fixed hidden groups reappearing on roster changes** -* (Raid Frames) **Fixed frames snapping to random positions on roster changes** -* (Raid Frames) **Fixed group order resetting on roster changes** -* (PvP) **Fixed health bars showing 100% in Battlegrounds** -* (PvP) Fixed self-healing cooldown not resetting on zone transitions -* (Test Mode) Fixed group visibility setting not applying in raid test mode -* (Test Mode) Fixed custom group display order not applying in raid test mode -* (Test Mode) Fixed "Columns Grow From" and "Reverse Order" dropdowns not updating flat raid test frames -* (Test Mode) Fixed layout settings not refreshing test frames when changed - -### New Features - -* (Range) **Range check fallback** — added a fallback for classes without friendly range check spells so out-of-range fading now works for all classes -* (Aura Designer) **Strata and frame level controls** — indicators can now be placed on different frame strata with a configurable default frame level -* (Test Mode) **Aura Designer support in test mode** — Aura Designer indicators now render on test frames -* (Aura Designer) **Out of range alpha** — new element-specific alpha slider for Aura Designer indicators (icons, squares, bars) - -### Improvements - -* (Test Mode) Redesigned test mode panel with collapsible sections, active count badges, and settings page quick-links - -## [4.0.13] - 2026-03-08 - -### Bug Fixes - -* (Click Casting) **Fixed keyboard click-cast bindings randomly stopping mid-hover** — keyboard-bound spells would sometimes stop working until the mouse left and re-entered the frame -* (Click Casting) Fixed spell transform procs (e.g. Flash of Light → Benediction) causing "Spell not Learned" errors -* (Click Casting) Fixed left-click casting randomly failing on some party/raid frames -* (Aura Blacklist) Fixed class dropdown overlapping text and not updating when selecting a different class -* (Auto Layouts) Fixed Aura Designer changes not saving when editing an auto layout a second time -* (Aura Designer) Fixed override indicators incorrectly appearing on internal proxy settings -* (Aura Designer) Fixed crash caused by corrupted saved data -* (Aura Designer) Fixed crash when swapping to a profile without Aura Designer settings - -### Improvements - -* (Missing Buff Icon) **Missing buff icons now work in combat** — previously they would disappear when entering combat -* (Missing Buff Icon) Added support for talent variant spell IDs (Mark of the Wild, Arcane Intellect) -* (Missing Buff Icon) Improved Blessing of the Bronze detection to cover all Evoker variants -* (Debug Console) Export now respects current severity and category filters -* (Aura Designer) Increased all X/Y offset slider ranges to -150 to 150 -* (Aura Designer) Grouped layout spacing slider now allows negative values for overlapping indicators -* (Aura Designer) Added "Reset to Global" button when editing auto layout overrides -* (Aura Designer) Editing banner no longer overlaps page controls - -## [4.0.12] - 2026-03-06 - -### New Features - -* **Multi-trigger frame effects** (Aura Designer) — a single frame effect (border, health bar color, etc.) can now trigger on any of multiple auras (e.g. show a border if Rejuvenation OR Regrowth OR Lifebloom is active) -* **Layout groups** (Aura Designer) — group placed indicators at a shared anchor with automatic flow positioning; when an aura is inactive, grouped indicators collapse without gaps -* **Spec-scoped aura configs** (Aura Designer) — configurations are now saved per-spec, so shared buffs like Prayer of Mending can have different indicator setups on each spec -* **Preview click-to-select** (Aura Designer) — left-click any indicator on the frame preview to jump to its settings; right-click to remove it -* **Duration and Stack text color** (Aura Designer) — new color pickers with alpha for duration text and stack text on icon and square indicators, available as both global defaults and per-indicator overrides -* **Hide Icon (Text Only)** (Aura Designer) — new checkbox on icon and square indicators that hides the icon visual while keeping duration and stack text visible -* **Cancel Targeting option** (Click Casting) — new per-binding checkbox in advanced settings that adds /stopspelltarget to the macro, preventing the blue targeting hand on certain spells. Disabled by default so spells like Rescue work correctly - -### Bug Fixes - -* (Frames) Fixed buff/debuff/defensive tooltips not showing when hovering aura icons -* (Frames) Fixed defensive bar icons not receiving hover events for tooltips -* (Frames) Fixed aura icons created during combat permanently losing tooltip hover after combat ends -* (Click Casting) Fixed smart resurrection not working on non-English WoW clients -* (Click Casting) Fixed click-casting "Spell not Learned" errors after talent changes -* (Click Casting) Fixed all click-casting bindings failing on non-English WoW clients - -## [4.0.11] - 2026-03-03 - -### Bug Fixes - -* Fixed target/focus/aggro highlights not showing on arena frames -* Fixed Aura Designer stack count font and outline settings not applying -* Fixed buff/debuff tooltips permanently breaking after combat until reload -* Fixed empty Buff Filters, Debuff Filters, and Defensives group containers showing when using Blizzard (default) aura mode -* Fixed Aura Designer health bar color overlay not restoring the correct color when the tracked buff expires -* Fixed Aura Designer health bar color overlay not matching the health bar texture -* Fixed Aura Designer health bar color not restoring correctly on login when a buff is already active -* Fixed party frames showing empty when loading into a follower dungeon -* Fixed Beacon of Virtue not available in the Aura Designer — it can now be configured with its own independent indicators -* Fixed Aura Designer spell icons changing when talent choice nodes replace a spell (e.g. Beacon of Light showing Beacon of Virtue's icon) - -### Improvements - -* Improved click-casting debug logging to help diagnose intermittent binding failures -* Added horizontal scrollbars to Aura Designer trackable auras and active effects strips - -## [4.0.10] - 2026-03-02 - -### Bug Fixes - -* Fixed addon managers (Wago, CurseForge) constantly prompting for updates due to stale version in TOC file — version is now updated as part of every release -* Fixed Aura Designer tracking buffs from other players instead of only your own casts - -### New Features - -* **Auto layout Copy To** — duplicate an auto layout (with all overrides) to any content type section, including same-section copying for different size ranges -* **Only My Buffs filter** — new toggle in Direct API buff filters that restricts all buff filters to player-cast buffs only (enabled by default); removes the now-redundant My Buffs sub-filter - -## [4.0.9] - 2026-03-02 - -### Bug Fixes - -* Fixed imported and duplicated profiles resetting to Default on reload/relog due to per-character SavedVariable not being synced - -### New Features - -* **Direct API buff filters** — added Not Cancelable, Big Defensives, and External Defensives as toggleable filter options -* **Additive filter logic** — enabled filters now use OR logic so selecting multiple categories shows the union (e.g. Raid In Combat + Big Defensives shows both) instead of requiring auras to match all selected filters -* **Defensive icon scanning** — defensive icon now detects both Big Defensives and External Defensives (e.g. Pain Suppression, Blessing of Sacrifice) -* **Filter tooltips** — hover any filter checkbox to see a description of what that category includes -* **Defensive bar spacing** — icon spacing slider now supports negative values for overlapping icons -* **Updated filter defaults** — buff filters default to Raid In Combat + Big Defensives + External Defensives; debuff filters default to Raid Debuffs + Crowd Control + Important Spells (migrated for existing users) - -## [4.0.8] - 2026-03-01 - -### New: Aura Designer - -Visual indicator system for tracking buffs, debuffs, and auras on your frames. -* **8 indicator types** — 3 placed indicators (Icon, Square, Bar) that occupy anchor points on the frame, plus 5 frame effects (Border, Health Bar Color, Name Text Color, Health Text Color, Frame Alpha) that affect the entire frame -* **Drag-to-place** — drag auras from the spell list onto any of 9 anchor points (corners, edges, center) with X/Y offset adjustment -* **Icon indicators** — spell icon with cooldown swipe, duration text, and stack count display -* **Square indicators** — colored square with cooldown swipe, duration text, and stack count -* **Bar indicators** — progress bar showing remaining duration with horizontal/vertical orientation, match-frame-width option, fill color, background color, and bar-color-by-time gradient -* **Border frame effect** — 5 styles: Solid, Animated, Dashed, Glow, and Corners Only with configurable thickness and color -* **Health Bar Color frame effect** — Replace or Tint mode with adjustable blend strength -* **Name/Health Text Color frame effects** — override unit name or health text color when an aura is active -* **Frame Alpha frame effect** — adjust entire frame transparency based on aura presence -* **Expiring system** — all 8 indicator types support an expiring color that activates below a configurable remaining-duration threshold, fully combat-safe -* **Priority stacking** — configurable priority per aura (1-20); frame effects only show the highest-priority active aura, placed indicators coexist on separate anchors -* **Buff coexistence** — standard buff icons can display alongside Aura Designer indicators, with a popup to choose when enabling AD -* **Global defaults** — configure default icon size, scale, duration/stack font, font scale, and outline style; new indicators inherit these automatically with per-indicator overrides available -* **Live preview** — indicators render on the frame preview in the options panel with adjustable zoom (0.75×–2.5×) -* **Per-spec aura lists** — curated aura lists for 8 healer and augmentation specs, auto-refreshes when switching specs - -### New: Auto Layouts (Raid Only) - -Automatically switches your raid frame layout based on content type and raid size. Does not apply to party, solo, or arena. -* **Three content categories** — Instanced/PvP (raids, dungeons, battlegrounds), Mythic (fixed 20-player), and Open World (world bosses, outdoor groups) -* **Per-size-range profiles** — create multiple layouts per content type, each covering a custom player range (e.g., 1-10, 11-20, 21-40). Mythic is a single fixed layout for 20 players -* **Automatic switching** — monitors group roster, zone changes, and instance type; applies the matching layout on-the-fly when content or raid size changes -* **Override-only storage** — each layout stores only the settings that differ from your global profile; everything else is inherited automatically -* **Full settings coverage** — overrides can include frame size, growth direction, groups per row, group visibility, bar colors, text settings, aura filters, icon toggles, pinned frame configuration, and more -* **Live editing** — click "Edit Settings" to enter editing mode with live frame preview; every change is tracked as an override with visual indicators showing which settings are modified vs global -* **Override indicators** — green checkmark for global values, orange star with reset button for modified values, per-tab override counts -* **Non-destructive** — your global profile is never modified; exiting editing mode restores your base settings cleanly -* **Crash recovery** — if editing is interrupted, the next login detects and restores your base settings -* **Status display** — shows current content type, instance name, raid size, active layout, and override count -* **Export/import support** — auto layout configurations included in profile exports - -### New: Aura System Improvements - -* **Direct Aura mode** — optional mode that gives full control over which buffs and debuffs appear using filter categories (Player, Raid, Big Defensive, etc.). Configure in Auras > Aura Filters -* **All Buffs / All Debuffs toggles** — master toggles to quickly show all buffs or all debuffs without configuring individual filters -* **Important Spells filter** — checkbox to show Blizzard's curated list of important buffs and debuffs -* **Buff deduplication** — buffs already displayed by the Defensive Bar or Aura Designer are automatically hidden from the buff bar. Enabled by default, toggle in Buffs tab -* **Multi-defensive icons** — Defensive Bar now shows all active big defensives simultaneously (up to configured max), not just one -* **Defensive bar compound growth** — growth direction now supports two-axis layouts (e.g., RIGHT_DOWN, LEFT_UP) with configurable wrap count -* Max buff and debuff icon count increased from 5 to 8 - -### New Features - -* Health fade system — fades frames above a configurable health threshold, with option to cancel fade when a dispellable debuff is active (contributed by X-Steeve) -* Class power pips — Holy Power, Chi, Combo Points, etc. displayed on the player frame as colored pips with configurable size, position, anchor, color, vertical layout, and role filter options (contributed by X-Steeve) -* "Sync with Raid/Party" toggle per settings page (contributed by Enf0) -* Per-class resource bar filter toggles -* Click-cast binding tooltip on unit frame hover — shows active bindings with usability status (contributed by riyuk) -* Health gradient color mode for missing health bar (contributed by Enf0) -* Click-cast binding tooltip moved to main Tooltip settings with full anchor and position controls -* Debug Console — in-game debug log viewer (`/df debug` to toggle, `/df console` to view) - -### Bug Fixes - -* Fix click-casting "script ran too long" error when many frames are registered (ElvUI, etc.) -* Fix health fade errors caused by Blizzard's protected health values -* Fix health fade not working correctly on pet frames, in test mode, and during health animation -* Fix profiles not persisting per character — each character now remembers their own active profile -* Fix pet frames vanishing after reload -* Fix pet frame font crash on non-English clients -* Fix party frame container not repositioning when dragging width or height sliders -* Fix resource bar border, color, and width issues after login/reload/resize -* Fix heal absorb bar showing smaller than actual absorb amount -* Fix absorb bar not fading when unit is out of range -* Fix name text truncation not applied to offline players -* Fix summon icon permanently stuck on frames after M+ start or group leave -* Fix icon alpha settings (role, leader, raid target, ready check) reverting to 100% after releasing slider -* Fix click-casting not working when clicking on aura/defensive icons -* Fix click-casting "Spell not learned" when queuing as different spec -* Fix DF click-casting not working until reload when first enabled -* Fix Clique compatibility — prevent duplicate registration, defer writes, commit all header children -* Fix aura click-through not updating safely on login -* Fix leader icon not updating on first leader change (contributed by riyuk) -* Fix Lua errors during Blizzard frame registration (contributed by riyuk) -* Fix missing raid groups when reloading UI during combat -* Fix duration text showing on permanent buffs -* Fix defensive icons showing stale data after entering/exiting vehicles -* Fix unit name getting stuck to the vehicle name after exiting a vehicle -* Fix follower dungeon only showing 2-3 party members until /reload -* Fix click-casting reload popup appearing on every login when the Clicked conflict warning is set to Ignore -* Fix dispel overlay sometimes treating all debuffs as dispellable -* Fix non-defensive buffs appearing in the Defensive Bar when units are out of range -* Fix raid mover frame (orange anchor) not resizing when frame settings change -* Fix group labels anchoring to the wrong player when sorting is enabled - -## [4.0.6] - 2026-02-15 - -### Bug Fixes - -* `/df resetgui` command now works — was referencing wrong frame variable, also shows the GUI after resetting -* Settings UI can now be dragged from the bottom banner in addition to the title bar -* Fix party frame mover (blue rectangle) showing wrong size after switching between profiles with different orientations or frame dimensions -* Fix Wago UI pack imports overwriting previous profiles — importing multiple profiles sequentially no longer corrupts the first imported profile -* Fix error when duplicating a profile - -## [4.0.5] - 2026-02-14 - -### Bug Fixes - -* Raid frames misaligned / anchoring broken -* Groups per row setting not working in live raids -* Arena/BG frames showing wrong layout after reload -* Arena health bars not updating after reload -* Leader change causes frames to disappear or misalign -* Menu bind ignores out-of-combat setting -* Boss aura font size defaulting to 200% instead of 100% -* Click casting profiles don't switch on spec change -* Clique not working on pet frames -* Absorb overlay doesn't fade when out of range -* Heal absorb and heal prediction bars don't fade when out of range -* Defensive icon flashes at wrong opacity when appearing -* Name text stays full opacity on out-of-range players -* Health text and status text stay full opacity on out-of-range players -* Name alpha resets after exiting test mode -* Glowy hand cursor after failed click cast spells -* Macro editing window gets stuck open when reopened -* Flat raid unlock mover sized incorrectly -* Fonts broken on non-English client languages - -### New Features - -* Click casting spec default profile option -* Group visibility options now available in flat raid mode -* Slider edit boxes accept precise decimal values for fine-tuned positioning and scaling -]===] +-- The changelog text itself lives in DandersFrames_Options/Changelog.lua: +-- it is a ~180 KB string only the settings panel ever reads, and holding it +-- resident cost every player that memory forever. Both files are GENERATED +-- by generate_changelog.sh -- edit CHANGELOG.md, not either output. diff --git a/DandersFrames/Core/Profile.lua b/DandersFrames/Core/Profile.lua index 5f944961..a2a068d0 100644 --- a/DandersFrames/Core/Profile.lua +++ b/DandersFrames/Core/Profile.lua @@ -608,6 +608,14 @@ end function DF:ExportProfile(categories, frameTypes, profileName) local L = DF.L + -- Selective export reads the category tables, which live in the companion + -- (~85 KB only import/export ever touches). Export is a deliberate user + -- action, so load them; a FULL export (categories == nil, the shape the + -- external DandersFrames_Export API uses) never needs them -- don't make + -- Wago pack exports pay for the panel. + if categories and not DF.ExportCategories and DF.EnsureOptionsLoaded then + if not DF:EnsureOptionsLoaded() then return nil end + end local LibSerialize = LibStub and LibStub("LibSerialize", true) local LibDeflate = LibStub and LibStub("LibDeflate", true) @@ -861,7 +869,11 @@ end -- Get info about what's in the import data function DF:GetImportInfo(importData) if not importData then return nil end - + -- Iterates DF.ExportCategories (companion-owned) unconditionally below. + if not DF.ExportCategories and DF.EnsureOptionsLoaded then + if not DF:EnsureOptionsLoaded() then return nil end + end + local info = { version = self:GetImportVersion(importData), hasParty = importData.party ~= nil, @@ -947,6 +959,14 @@ function DF:ApplyImportedProfile(importData, selectedCategories, selectedFrameTy local L = DF.L if not importData then return false end + -- A SELECTIVE payload (importData.categories set) merges through the + -- category tables in the companion. Import is a deliberate user action -- + -- and an external caller (Wago pack) could hand us a selective string with + -- the panel closed -- so load rather than nil-index. Full imports skip this. + if importData.categories and not DF.ExportCategories and DF.EnsureOptionsLoaded then + if not DF:EnsureOptionsLoaded() then return false end + end + local importInfo = self:GetImportInfo(importData) -- Default to all available frame types diff --git a/DandersFrames/DandersFrames.toc b/DandersFrames/DandersFrames.toc index b596b28d..864b0d5e 100644 --- a/DandersFrames/DandersFrames.toc +++ b/DandersFrames/DandersFrames.toc @@ -54,7 +54,6 @@ DandersFrames.xml Core\Config.lua Core\Changelog.lua Core\Profile.lua -Core\ExportCategories.lua Core.lua Designer\Presets.lua Core\RosterEvents.lua @@ -75,7 +74,6 @@ Debug\Profiler.lua # Foreign-attachment scan (/df attached) — diagnoses other addons anchored to our frames # GUI System -GUI\IconLib.lua # GUI toolkit, split from one 13k-line file. Order is load-bearing: GUI.lua # creates the GUI table plus GUI._state / GUI._priv, which every later part # re-declares aliases from. diff --git a/DandersFrames/Debug/Profiler.lua b/DandersFrames/Debug/Profiler.lua index 0d2c76bb..1dfcc27a 100644 --- a/DandersFrames/Debug/Profiler.lua +++ b/DandersFrames/Debug/Profiler.lua @@ -1,2173 +1,1559 @@ -local addonName, DF = ... - --- ============================================================ --- DANDERSFRAMES FUNCTION PROFILER --- --- Zero overhead when disabled: uses function-swapping to wrap --- DF:Method() calls with debugprofilestop() timing. When stopped, --- original functions are fully restored — no runtime checks, --- no wrappers, no cost. --- --- Two things make the numbers trustworthy rather than merely indicative: --- * Self time. Profiled functions call other profiled functions, so every --- Total contains its children. Self strips them back out, and it is what --- the % column ranks — see the CALL STACK block below. --- * Overhead subtraction. The wrapper's own in-window cost is measured on --- the machine at Start and removed from every figure, so a function --- called 200,000 times is not penalised against one called 200. --- Both are corrections, not guarantees; the residuals are documented at --- CalibrateOverhead. --- --- Usage: --- /df debug profiler Toggle the profiler UI --- /df debug profile [seconds] Quick run for N seconds (default 10) --- ============================================================ - -local debugprofilestop = debugprofilestop -local collectgarbage = collectgarbage -local format = string.format -local sort = table.sort -local floor = math.floor -local max = math.max -local wipe = wipe -local pairs = pairs -local ipairs = ipairs -local tostring = tostring -local CreateFrame = CreateFrame - --- Shared tick counter, incremented once per OnUpdate frame. --- Wrapped functions read this via upvalue to detect "new tick" without --- needing access to any timer state. -local tickRef = { 0 } - --- ============================================================ --- CALL STACK (for exclusive / "self" time) --- ---------------------------------------------------------- --- Every wrapper is a stack frame. On the way in it pushes a slot; on the --- way out it adds its own inclusive time and call count into its PARENT's --- slot. Self time is then just `elapsed - childMs[myDepth]`. --- --- Why this matters: wrapped functions call other wrapped functions — --- UpdateAllAuras -> UpdateAuras_Enhanced, and UpdateTextDesigner -> --- Render.UpdateFrame -> Resolver.Resolve three deep by design. Every level's --- `total` contains its children, so summing the rows counted the same --- microsecond two and three times and the % column was a share of an --- invented denominator. Self time sums correctly; inclusive time does not. --- --- Only WRAPPED children are subtracted, which is exactly right: time in an --- unwrapped local helper genuinely belongs to the function that called it. --- --- ☠ callDepth must never be allowed to ratchet. A Lua error inside a --- profiled function unwinds past the decrement, leaving the counter high --- forever and every subsequent call recorded as somebody's child. The tick --- driver below resets it every frame, which is safe because nothing here --- yields: no DF call is ever still on the stack when a new frame starts. -local callDepth = 0 -local childMs = {} -- [depth] = inclusive ms of wrapped children at that depth -local childN = {} -- [depth] = count of wrapped calls beneath that depth - --- ============================================================ --- STATE --- ============================================================ - -local Profiler = { - active = false, - startTime = 0, -- debugprofilestop() ms when started - stopTime = 0, -- debugprofilestop() ms when stopped (for accurate elapsed) - combatAuto = false, -- auto start/stop on combat enter/leave - splitByFrame = false, -- show per-frame-type breakdown - viewMode = "functions", -- "functions" | "events" - overheadMs = 0, -- measured cost the wrapper adds to each call (ms) - data = {}, -- [funcName|type] = { calls, total, selfMs, descN, max, mem } - tickStats = {}, -- [funcName] = { lastTick, calls, maxPerTick } - originals = {}, -- [funcPath] = { table, key, original } - eventData = {}, -- [eventName] = { calls, total, max, mem, source } - eventOriginals = {}, -- [dispatcherKey] = { container, key, original } - updateData = {}, -- [frameLabel] = { calls, total, max, mem } - sortColumn = "total", - sortDesc = true, -} - -DF.Profiler = Profiler - --- Forward declarations (referenced by combat handler before defined below) -local profilerFrame = nil -local dataRows = {} -local headerTexts = {} -local UpdateUI -- assigned later when UI functions are defined - --- Combat auto-profile event frame (created once, persists) -local combatFrame = CreateFrame("Frame") -combatFrame:Hide() - --- Per-tick driver: increments tickRef[1] every OnUpdate frame so wrapped --- functions can detect "new tick" and aggregate per-tick call counts. --- Hidden when profiler is stopped (no OnUpdate runs). -local tickFrame = CreateFrame("Frame") -tickFrame:Hide() -tickFrame:SetScript("OnUpdate", function() - tickRef[1] = tickRef[1] + 1 - -- Self-heal the call-stack depth (see the ☠ note on callDepth). A clean - -- frame always ends at 0, so this is a no-op except after an error. - callDepth = 0 -end) - --- ============================================================ --- ONUPDATE REGISTRY + SETSCRIPT HOOK --- ---------------------------------------------------------- --- Tracking OnUpdate handlers requires a different mechanism than --- function or event wrapping. OnUpdate scripts are bound directly to --- frames via SetScript, so there's no DF-owned function we can swap. --- --- Strategy: hook Frame:SetScript at addon load. Whenever a *DF-owned* --- frame installs/removes an OnUpdate handler, we record the latest --- handler in a registry. The registry alone has effectively zero --- overhead — just one table assignment per SetScript call. No wrapping, --- no instrumentation. --- --- On Profiler:Start, we walk the registry and replace each recorded --- handler with a wrapped version that records timing + memory. New --- OnUpdate handlers installed *after* Start are wrapped on the spot --- by the same hook. On Stop, all originals are restored. --- --- The `installingOnUpdate` guard prevents the hook from re-wrapping --- our own wrapper as we install it (otherwise infinite recursion). --- --- CRITICAL: we must filter to DF-owned frames only. If we wrap a --- non-DF frame's OnUpdate (especially Blizzard's CompactRaidFrame --- secure frames), our wrapper closure runs inside that frame's --- update cascade and the execution context gets marked as --- "tainted by DandersFrames". That taint propagates to every --- UnitIsConnected / UnitHealthMax / etc. secret-value return and --- every protected Show()/Hide() call downstream — breaking secure --- Blizzard code and producing ADDON_ACTION_BLOCKED errors. This was --- the cause of a raid-session bug report on 2026-04-08. --- --- The filter walks the frame's parent chain looking for any ancestor --- whose name starts with "Danders" or "DF" — DF's named containers --- and headers all use these prefixes (DandersPartyHeader, --- DandersArenaHeader, DandersRaidGroupN, DFTestHeader, etc.). If no --- ancestor has a DF name, the frame is foreign and must be skipped. --- ============================================================ - -local onUpdateRegistry = {} -- [frame] = handler ref currently bound (or nil) -local onUpdateLabels = {} -- [frame] = stable label for display -local onUpdateDFCheck = {} -- [frame] = true/false cached IsDFFrame result -local onUpdateWrapped = {} -- [frame] = { original, stats } (only while active) -local installingOnUpdate = false -- re-entry guard for the SetScript hook - --- Is this frame owned by DandersFrames? Walks up the parent chain --- looking for an ancestor whose name starts with "Danders" or "DF". --- DF's secure headers and containers are all explicitly named with --- one of these prefixes (see Frames/Headers.lua). Anonymous DF child --- frames inherit their DF-ness from a named ancestor, so the walk --- catches them too. --- --- Results are cached per-frame because this is called from the --- SetScript hook on every OnUpdate (re)bind and we don't want to --- re-walk the parent chain every time. -local function IsDFFrame(frame) - local cached = onUpdateDFCheck[frame] - if cached ~= nil then return cached end - - local walker = frame - local depth = 0 -- safety cap against pathological cycles - while walker and depth < 32 do - -- pcall because some Blizzard template frames (e.g. RadialWheel - -- wedge buttons) error on :GetName() with "bad self". - local ok, name = pcall(walker.GetName, walker) - if ok and name then - local p2 = name:sub(1, 2) - local p7 = name:sub(1, 7) - if p7 == "Danders" or p2 == "DF" then - onUpdateDFCheck[frame] = true - return true - end - end - local ok2, parent = pcall(walker.GetParent, walker) - walker = (ok2 and parent) or nil - depth = depth + 1 - end - - onUpdateDFCheck[frame] = false - return false -end - --- Best-effort label for a frame. Uses GetDebugName() when available --- (modern WoW gives a parent-chain path), falls back to GetName(), --- finally a generic "". Captured once at first sighting so it --- stays stable across renames. -local function ResolveFrameLabel(frame) - local existing = onUpdateLabels[frame] - if existing then return existing end - local label - if frame.GetDebugName then - label = frame:GetDebugName() - end - if (not label or label == "") and frame.GetName then - label = frame:GetName() - end - if not label or label == "" then - label = "" - end - onUpdateLabels[frame] = label - return label -end - --- Forward declaration so the SetScript hook can call WrapFrameOnUpdate --- before it's defined further down (it lives inside Profiler:Start's --- closure scope and needs to share state with this hook). -local WrapFrameOnUpdate - --- ============================================================ --- ONUPDATE TRACKING HOOK --- ============================================================ --- ☠ SAVEDVARIABLES ARE NOT LOADED WHEN THIS FILE EXECUTES. --- This used to read DandersFramesDB_v2.profilerOnUpdateHook right here, at file --- scope, and install the hook only if it was true. The global does not exist --- yet at that point — it is populated before ADDON_LOADED, which is AFTER every --- addon file has run — so the read was always nil and the hook was NEVER --- installed, no matter what the saved file said. Ticking the box, reloading, --- and finding it still off was that, exactly: the value saved correctly and was --- then read a frame too early, forever. --- Every other consumer of this table in the addon reads it inside a function --- (DF:GetGlobalDB and friends) and so never hit this. --- --- ⚠ The install CANNOT simply move to ADDON_LOADED either: this file sits at --- TOC line 68 deliberately (see the "Profiler.lua is loaded earlier" note in --- the TOC) so the hook is in place before the files that install an OnUpdate at --- THEIR file scope — five ticker/throttle singletons across Headers, StatusIcons --- and AutoProfiles, all of which load later. (A sixth lived in Performance.lua, --- since deleted.) Installing at --- ADDON_LOADED would miss every one of them. --- --- So: install unconditionally and record from the first moment, then resolve the --- user's setting at ADDON_LOADED and drop everything if they had it off. The --- cost of being wrong for that one window is a table of a few dozen entries; --- the cost of the hook itself when latched off is the two comparisons below, --- since scriptType ~= "OnUpdate" rejects almost every SetScript call in the game --- before anything else happens. -local onUpdateHookEnabled = nil -- nil = undecided (still loading); resolved below -Profiler.onUpdateHookEnabled = onUpdateHookEnabled - -local frameMeta = getmetatable(CreateFrame("Frame")).__index -hooksecurefunc(frameMeta, "SetScript", function(frame, scriptType, handler) - if onUpdateHookEnabled == false then return end -- latched off at ADDON_LOADED - if installingOnUpdate then return end - if scriptType ~= "OnUpdate" then return end - if not IsDFFrame(frame) then return end -- skip non-DF frames (taint safety) - - if handler then - onUpdateRegistry[frame] = handler - ResolveFrameLabel(frame) - -- If profiler is currently recording, wrap the new handler - -- right now so this newly added OnUpdate is visible from its - -- first frame. ⚠ Except our own window: opening the profiler UI - -- mid-recording installs its refresh handler through this hook, and - -- Profiler:Start's exclusion list only covers frames that already - -- existed. Both doors need the same guard. - if Profiler.active and WrapFrameOnUpdate - and frame ~= tickFrame and frame ~= profilerFrame then - WrapFrameOnUpdate(frame, handler) - end - else - -- nil handler = OnUpdate removed; drop bookkeeping. - onUpdateRegistry[frame] = nil - if onUpdateWrapped[frame] then - onUpdateWrapped[frame] = nil - end - end -end) - --- Resolve the setting at the first moment SavedVariables actually exist. -local hookSettingFrame = CreateFrame("Frame") -hookSettingFrame:RegisterEvent("ADDON_LOADED") -hookSettingFrame:SetScript("OnEvent", function(self, _, loadedAddon) - if loadedAddon ~= addonName then return end - self:UnregisterEvent("ADDON_LOADED") - onUpdateHookEnabled = (DandersFramesDB_v2 - and DandersFramesDB_v2.profilerOnUpdateHook == true) or false - Profiler.onUpdateHookEnabled = onUpdateHookEnabled - if not onUpdateHookEnabled then - -- Opted out: throw away what the load window collected and latch the - -- hook off. Nothing else in the profiler reads these while disabled. - wipe(onUpdateRegistry) - wipe(onUpdateLabels) - wipe(onUpdateDFCheck) - end -end) - -combatFrame:SetScript("OnEvent", function(self, event) - if event == "PLAYER_REGEN_DISABLED" then - -- Entering combat - if not Profiler.active then - Profiler:Start() - if profilerFrame and profilerFrame:IsShown() and UpdateUI then - UpdateUI() - end - end - elseif event == "PLAYER_REGEN_ENABLED" then - -- Leaving combat - if Profiler.active then - Profiler:Stop() - Profiler:PrintResults() - if profilerFrame and profilerFrame:IsShown() and UpdateUI then - UpdateUI() - end - end - end -end) - -function Profiler:SetCombatAuto(enabled) - self.combatAuto = enabled - if enabled then - combatFrame:RegisterEvent("PLAYER_REGEN_DISABLED") - combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED") - DF:Say("Combat auto-profile |cff00ff00ON|r — will start on combat, stop + print on combat end.") - else - combatFrame:UnregisterEvent("PLAYER_REGEN_DISABLED") - combatFrame:UnregisterEvent("PLAYER_REGEN_ENABLED") - DF:Say("Combat auto-profile |cffff4444OFF|r") - end -end - --- ============================================================ --- PROFILED FUNCTIONS --- Each entry is a path string. Plain "Name" resolves to DF[Name]. --- Dotted "Mod.Sub.Name" resolves to DF.Mod.Sub.Name. Missing entries --- are silently skipped, so it's safe to list optional features here. --- ============================================================ - -local PROFILED_FUNCTIONS = { - -- ---------------------------------------------------------- - -- Core per-unit updates (event hot path) - -- ---------------------------------------------------------- - "UpdateUnitFrame", - "UpdateHealthFast", -- Lean UNIT_HEALTH hot path - "UpdateHealth", - "UpdatePower", - "UpdateName", - "UpdateFrame", - "UpdateResourceBar", - - -- ---------------------------------------------------------- - -- Aura pipeline (12.1 factory: per-event cost is the sig-compare - -- walk in the Drive* functions; Blizzard renders the icons) - -- ---------------------------------------------------------- - "UpdateAuras_Enhanced", - "RebuildDirectFilterStrings", - "DriveBuffFactory", - "DriveDebuffFactory", - "DriveDefensiveFactory", - "DriveMissingBuffFactory", - "RefreshFactoryRows", - "BuildAuraRowConfig", - "BuildDebuffFilterRecords", - "FilterRegistry.ResolveSelection", - "FilterRegistry.SelectionSignature", - - -- ---------------------------------------------------------- - -- Dispel - -- ---------------------------------------------------------- - "UpdateDispelOverlay", - "UpdateDispelGradientHealth", - "UpdateAllDispelOverlays", - - -- ---------------------------------------------------------- - -- Absorb / Heal Prediction - -- ---------------------------------------------------------- - "UpdateAbsorb", - "UpdateHealAbsorb", - "UpdateHealPrediction", - - -- ---------------------------------------------------------- - -- Range - -- ---------------------------------------------------------- - "UpdateRange", - "UpdatePetRange", - "RefreshRangeSpell", - - -- ---------------------------------------------------------- - -- Visual / Highlights / Health Fade - -- ---------------------------------------------------------- - "UpdateHighlights", - "UpdateAnimatedBorder", - "ApplyDeadFade", - "ApplyHealthColors", - "ApplyBarOrientation", - "ApplyHealthFadeAlpha", - "UpdateHealthFade", - "UpdatePetHealthFade", - - -- ---------------------------------------------------------- - -- Status icons (per-unit) - -- ---------------------------------------------------------- - "UpdateRoleIcon", - "UpdateLeaderIcon", - "UpdateRaidTargetIcon", - "UpdateReadyCheckIcon", - "UpdateCenterStatusIcon", - "UpdateSummonIcon", - "UpdateResurrectionIcon", - "UpdatePhasedIcon", - "UpdateAFKIcon", - "UpdateVehicleIcon", - "UpdateRaidRoleIcon", - "UpdateAllStatusIcons", -- per-frame sweep of all icons - "UpdateRestedIndicator", - - -- ---------------------------------------------------------- - -- Defensive / external def icons + missing-buff - -- ---------------------------------------------------------- - "UpdateMissingBuffIcon", - "UpdateExternalDefIcon", - "UpdateDefensiveBar", - - -- ---------------------------------------------------------- - -- Aura Designer (per-frame) - -- ---------------------------------------------------------- - "GetADTrackedSpellIDs", - "GetClaimedDebuffCategories", - "BuildADIdentityFilters", - "AuraDesigner.Factory.SyncFrame", - "AuraDesigner.Factory.ClearFrame", - - -- ---------------------------------------------------------- - -- Text Designer - -- Driven from the central header dispatcher (tdRefresh, Frames/Headers.lua) - -- once per event per frame — plus the pinned mirror — and "health" is one of - -- the hints. On 12.1 the aura rows render game-side, so this is plausibly - -- the largest per-frame consumer LEFT IN LUA. It was absent from this table - -- entirely, which made the system most worth measuring invisible. - -- - -- Three levels on purpose: the DF: entry gives the total, UpdateFrame is the - -- per-frame worker, and Resolve is per ELEMENT — the multiplier that turns a - -- cheap frame into an expensive one. - -- - -- ⚠ Resolve is the highest-CALL-COUNT target in this table (elements × - -- frames × ticks), which is exactly the shape the overhead correction - -- exists for: a per-call tax lands hardest on the row with the most calls. - -- Its figure is now overhead-corrected rather than an upper bound. (The - -- two collectgarbage("count") calls the wrapper makes never inflated it — - -- they sit outside the timed window. They cost the GAME time while - -- profiling, but they were never charged to the function.) - -- - -- ⚠ Resolve lands in the UNCLASSIFIED ("?") bucket, and that is correct, not - -- a bug: the bucket is picked from the first argument's .unit, and Resolve - -- takes (elem, source) — an element config, not a frame. The other two are - -- frame-first and split party/raid/pinned normally. - -- ---------------------------------------------------------- - "UpdateTextDesigner", - "TextDesigner.Render.UpdateFrame", - "TextDesigner.Resolver.Resolve", - - -- (Removed) the Targeted Spells entry "UpdateTargetedSpellLayout" — that function - -- went with the group-frame display. Not a silent stale entry: Profiler:Start - -- reports targets it cannot resolve, so this printed an unresolved-target line on - -- every profiler start. - - -- ---------------------------------------------------------- - -- Pets - -- ---------------------------------------------------------- - "UpdatePetFrame", - "UpdatePetHealth", - "UpdatePetName", - "ApplyPetFrameStyle", - - -- ---------------------------------------------------------- - -- Layout / Style (called per-frame on layout changes) - -- ---------------------------------------------------------- - "ApplyFrameLayout", - "ApplyFrameStyle", - "FullFrameRefresh", - - -- ---------------------------------------------------------- - -- Bulk sweeps (called on roster / settings events) - -- ---------------------------------------------------------- - "UpdateAllFrames", - "UpdateAllPetFrames", - "UpdateAllRaidPetFrames", - "UpdateAllPetFramePositions", - "UpdateAllAuras", - "UpdateAllMissingBuffIcons", - "UpdateAllFramesStatusIcons", - "UpdateAllRoleIcons", - "UpdateAllDefensiveBars", - "UpdateAllExternalDefIcons", - "UpdateAllElementAppearances", - "UpdateAllFrameAppearances", - "RefreshLiveFrames", - "RefreshAllVisibleFrames", - "RefreshRaidGroupFrames", - "RefreshPartyFrames", - "RefreshAllHeaderChildFrames", - "RefreshRaidFlatFrames", - "UpdateLiveRaidFrames", - - -- ---------------------------------------------------------- - -- Roster handling (called on GROUP_ROSTER_UPDATE) - -- ---------------------------------------------------------- - "ProcessRosterUpdate", - "ProcessRoleUpdate", - "RebuildUnitFrameMap", - "UpdateHeaderFrameCount", - "UpdateRaidGroupOrderAttributes", - "ApplyRaidGroupSorting", - "ApplyPartyGroupSorting", - - -- ---------------------------------------------------------- - -- Power event registration (changes when role/spec/group changes) - -- ---------------------------------------------------------- - "UpdatePowerEventRegistration", - "UpdateAllPowerEventRegistration", - - -- ---------------------------------------------------------- - -- Blizzard integration - -- ---------------------------------------------------------- - "UpdateBlizzardFrameVisibility", -} - --- ============================================================ --- PROFILED EVENT DISPATCHERS --- Each entry points at a function on DF that the addon's event frames --- call through. Wrapping these gives us per-event timing for everything --- those frames receive — without touching individual modules. --- ============================================================ - -local PROFILED_EVENT_DISPATCHERS = { - -- Roster UNIT_* events: every UNIT_AURA / UNIT_HEALTH / UNIT_POWER - -- for every roster member flows through this single trampoline. - { container = DF, key = "_RouteRosterEvent", source = "Roster" }, - -- Main eventFrame: ADDON_LOADED, GROUP_ROSTER_UPDATE, - -- PLAYER_REGEN_*, PLAYER_SPECIALIZATION_CHANGED, etc. - { container = DF, key = "_MainEventDispatcher", source = "Main" }, -} - --- ============================================================ --- FORMAT HELPERS --- ============================================================ - -local function CommaNumber(n) - local s = tostring(floor(n)) - return s:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "") -end - -local function FormatMs(ms) - if ms >= 1000 then - return format("%.1fs", ms / 1000) - elseif ms >= 0.1 then - return format("%.1f", ms) - else - return format("%.2f", ms) - end -end - -local function FormatUs(ms) - local us = ms * 1000 - if us >= 100 then return format("%.0f", us) - elseif us >= 10 then return format("%.1f", us) - else return format("%.2f", us) end -end - --- Bytes per call: small numbers as raw bytes, larger as KB. --- "0" -> "·" so the row reads cleanly when there's no allocation. -local function FormatBytes(b) - if b <= 0 then return "·" end - if b >= 1024 * 10 then - return format("%.0fk", b / 1024) - elseif b >= 1024 then - return format("%.1fk", b / 1024) - elseif b >= 100 then - return format("%.0f", b) - else - return format("%.0f", b) - end -end - -local function FormatPeak(n) - if n <= 0 then return "·" end - if n >= 1000 then return format("%.1fk", n / 1000) end - return tostring(n) -end - -local function FormatElapsed(seconds) - if seconds >= 60 then - return format("%dm %ds", floor(seconds / 60), floor(seconds % 60)) - else - return format("%.1fs", seconds) - end -end - --- ============================================================ --- OVERHEAD CALIBRATION --- ============================================================ --- The wrapper cannot measure a function without also measuring a little of --- itself. What lands INSIDE the timed window is the tail of the opening --- debugprofilestop, the vararg pack and 5-return dispatch into the original, --- and the head of the closing debugprofilestop. Small — but it is charged --- once per call, so it scales with call COUNT, not with cost. Left --- uncorrected it quietly taxes the hot-and-cheap functions (Resolver.Resolve --- runs elements x frames x ticks) and barely touches the cold-and-expensive --- ones, which is precisely backwards from what you want when ranking. --- --- So measure it: run the identical pattern around a function that does --- nothing, and whatever it reports IS the overhead. --- --- ⚠ THE ESTIMATOR IS THE MINIMUM OF BATCH MEANS, and the batches are kept --- SHORT on purpose. Averaging within a batch is what defeats timer --- quantisation — individual samples of a do-nothing call frequently read 0, --- so a minimum over raw samples would report ~0 and under-subtract, which is --- worse than not correcting at all. Taking the minimum ACROSS batches is what --- discards contamination: a batch that caught a GC step reads high, and only --- the cleanest batch is kept. Long batches break that, because with enough --- iterations every batch catches a GC step and the "clean" floor is a floor --- of dirty numbers. Hence many short batches rather than a few long ones. --- --- ⚠ KEEP THIS LOOP SHAPED LIKE THE REAL WRAPPER. It mimics the call form --- deliberately: two args plus varargs in, five returns captured out. Change --- the wrappers' call form and this stops measuring the same thing, and the --- correction silently becomes wrong rather than absent. --- --- What this does NOT remove: the rest of the wrapper (the two --- collectgarbage("count") calls, the bucket writes, the stack push/pop) sits --- outside the window and so never enters this row's own numbers — but for a --- NESTED call it does land inside its parent's window, where nothing can --- separate it from real work. Deeply-nested rows therefore keep a small --- residual. It is bounded by (wrapped calls beneath) x (a fraction of a --- microsecond) and is the reason the legend says "corrected", not "exact". --- 25 x 80 = the same 2000 samples as the original 5 x 500, redistributed so a --- GC-free batch is likely to exist. See the estimator note above before changing --- these: raising ITERS while lowering BATCHES quietly re-breaks the correction. -local CAL_BATCHES = 25 -local CAL_ITERS = 80 - -local function CalibrateOverhead() - local noop = function() end - local dummy = {} - - -- One wrapper-shaped measurement. Returns what the wrapper would have - -- recorded for a call that costs nothing. - local function measureOnce(selfArg, a1, ...) - local t0 = debugprofilestop() - local r1, r2, r3, r4, r5 = noop(selfArg, a1, ...) - return debugprofilestop() - t0 - end - - -- Warm-up: first calls through a fresh closure are not representative. - for _ = 1, CAL_ITERS do measureOnce(dummy, 1) end - - local best - for _ = 1, CAL_BATCHES do - local sum = 0 - for _ = 1, CAL_ITERS do - sum = sum + measureOnce(dummy, 1) - end - local mean = sum / CAL_ITERS - if not best or mean < best then best = mean end - end - - return best or 0 -end - --- Strip the instrumentation's own cost out of one row. --- ownCalls invocations of this row itself --- deepCalls wrapped invocations nested beneath it — their in-window --- overhead is sitting inside this row's INCLUSIVE total, so it --- comes off the total but not off self time. --- Clamped: a function genuinely cheaper than the wrapper reads as 0 rather --- than negative, and self can never exceed the corrected inclusive total. -local function Corrected(totalMs, selfMs, ownCalls, deepCalls) - local oh = Profiler.overheadMs - if oh <= 0 then return totalMs, selfMs end - local t = totalMs - oh * (ownCalls + (deepCalls or 0)) - local s = selfMs - oh * ownCalls - if t < 0 then t = 0 end - if s < 0 then s = 0 end - if s > t then s = t end - return t, s -end - --- (Removed) SumCorrected. It walked the raw buckets and corrected each one, while --- GetSortedResults aggregates a function's per-frame-type buckets FIRST and corrects --- the total once. Those are not the same number: Corrected clamps at zero, so --- correcting five buckets independently and adding them loses the negative headroom --- that a single combined correction keeps. The status line and the % denominator --- could therefore disagree. GetGrandTotalMs now asks GetSortedResults instead, so --- there is one aggregation and they cannot drift apart. - --- ============================================================ --- CORE PROFILING --- ============================================================ - --- Resolve a dotted path like "Mod.Sub.Name" against the DF table. --- Returns: container_table, leaf_key, current_value (or nil if anything is missing). -local function ResolveFunctionPath(path) - local container = DF - local lastDot = 1 - while true do - local dot = path:find(".", lastDot, true) - if not dot then - local key = path:sub(lastDot) - return container, key, container[key] - end - local segment = path:sub(lastDot, dot - 1) - container = container[segment] - if type(container) ~= "table" then return nil end - lastDot = dot + 1 - end -end - --- Wrap a single frame's OnUpdate handler. Idempotent: a second call on --- the same frame is a no-op so prospective wrapping from the SetScript --- hook can't double-instrument. Stats are keyed by the frame's label, --- which means multiple frames sharing a name (rare, e.g. pooled frames) --- aggregate into one row — usually what you want. -WrapFrameOnUpdate = function(frame, original) - if onUpdateWrapped[frame] then return end - - local label = ResolveFrameLabel(frame) - local stats = Profiler.updateData[label] - if not stats then - stats = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - Profiler.updateData[label] = stats - end - - local wrapped = function(self, elapsed, ...) - -- OnUpdate handlers are stack ROOTS in practice (the C layer calls - -- them sequentially, never nested) but they call plenty of wrapped DF - -- methods, so they push a frame like everything else — that is what - -- keeps "time in the handler itself" separate from "time in the - -- functions it drove". - local d = callDepth + 1 - callDepth = d - childMs[d] = 0 - childN[d] = 0 - - local m0 = collectgarbage("count") - local t0 = debugprofilestop() - original(self, elapsed, ...) - local elapsedMs = debugprofilestop() - t0 - local mDelta = collectgarbage("count") - m0 - if mDelta < 0 then mDelta = 0 end - - local ct, cn = childMs[d], childN[d] - callDepth = d - 1 - if d > 1 then - childMs[d - 1] = childMs[d - 1] + elapsedMs - childN[d - 1] = childN[d - 1] + cn + 1 - end - local selfDelta = elapsedMs - ct - if selfDelta < 0 then selfDelta = 0 end - - stats.calls = stats.calls + 1 - stats.total = stats.total + elapsedMs - stats.selfMs = stats.selfMs + selfDelta - stats.descN = stats.descN + cn - stats.mem = stats.mem + mDelta - if elapsedMs > stats.max then stats.max = elapsedMs end - end - - onUpdateWrapped[frame] = { original = original, wrapped = wrapped } - - installingOnUpdate = true - frame:SetScript("OnUpdate", wrapped) - installingOnUpdate = false -end - -function Profiler:Start() - if self.active then - DF:Say("Already recording.") - return - end - - -- Measure the wrapper's own in-window cost before anything is wrapped, so - -- the correction reflects THIS machine under THESE conditions rather than - -- a constant baked in at authoring time. Taken before startTime so the - -- calibration loop itself never lands inside the profiled window. - self.overheadMs = CalibrateOverhead() - - self.active = true - self.startTime = debugprofilestop() - self.stopTime = 0 - self:_SnapshotContainerStats() - self.containerDelta = nil - wipe(self.data) - wipe(self.tickStats) - wipe(self.originals) - wipe(self.eventData) - wipe(self.eventOriginals) - wipe(self.updateData) - tickRef[1] = 0 - callDepth = 0 - - local wrapped = 0 - -- ☠ An unresolved target used to vanish without a trace: the branch below - -- just skips it, and the "N functions instrumented" line gives no clue that - -- N is short. Rename or delete a profiled function and it silently stops - -- being measured — you read a clean profile and conclude the system is - -- cheap. Collect the misses and name them. - local missing = {} - local typeCheck = type -- cache as upvalue - - for _, path in ipairs(PROFILED_FUNCTIONS) do - local container, key, original = ResolveFunctionPath(path) - local resolved = container and type(original) == "function" - if not resolved then missing[#missing + 1] = path end - if resolved then - -- Save originals so Stop() can fully restore. - self.originals[path] = { container = container, key = key, original = original } - - -- Per-frame-type buckets. Each entry tracks: calls, total - -- (inclusive) ms, selfMs (exclusive of wrapped children), descN - -- (wrapped calls beneath, for the overhead correction), max - -- single-call ms, and total memory delta (KB). - local dP = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - local dR = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - local dHP = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - local dHR = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - local dU = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - - self.data[path .. "|P"] = dP - self.data[path .. "|R"] = dR - self.data[path .. "|HP"] = dHP - self.data[path .. "|HR"] = dHR - self.data[path .. "|?"] = dU - - -- Per-function tick stats: how many times has this function been - -- called within the current frame tick? Tracks the worst tick. - local ts = { lastTick = -1, calls = 0, maxPerTick = 0 } - self.tickStats[path] = ts - - local orig = original - local tref = tickRef -- upvalue cache - - container[key] = function(selfArg, a1, ...) - -- Tick spike accounting (cheap: 1 table read + 2 compares) - local cur = tref[1] - if ts.lastTick ~= cur then - ts.lastTick = cur - ts.calls = 0 - end - ts.calls = ts.calls + 1 - if ts.calls > ts.maxPerTick then - ts.maxPerTick = ts.calls - end - - -- Push a call-stack frame. Deliberately OUTSIDE the timed - -- window below: this bookkeeping is instrumentation, not the - -- function's cost, and charging it here would defeat the - -- correction it exists to enable. - local d = callDepth + 1 - callDepth = d - childMs[d] = 0 - childN[d] = 0 - - -- Time + memory delta around the call. - -- collectgarbage("count") returns kilobytes; deltas are exact - -- between calls, but a GC cycle running mid-call shows as - -- negative — we clamp to 0 to avoid skew. - local m0 = collectgarbage("count") - local t0 = debugprofilestop() - local r1, r2, r3, r4, r5 = orig(selfArg, a1, ...) - local elapsed = debugprofilestop() - t0 - local mDelta = collectgarbage("count") - m0 - if mDelta < 0 then mDelta = 0 end - - -- Pop: hand our inclusive time and call count up to whoever - -- called us, so THEIR self time excludes us. - local ct, cn = childMs[d], childN[d] - callDepth = d - 1 - if d > 1 then - childMs[d - 1] = childMs[d - 1] + elapsed - childN[d - 1] = childN[d - 1] + cn + 1 - end - local selfDelta = elapsed - ct - if selfDelta < 0 then selfDelta = 0 end - - -- Classify: 2-3 field lookups on the first argument - local bucket - if typeCheck(a1) == "table" and a1.unit then - if a1.isPinnedFrame then - bucket = a1.isRaidFrame and dHR or dHP - else - bucket = a1.isRaidFrame and dR or dP - end - else - bucket = dU - end - bucket.calls = bucket.calls + 1 - bucket.total = bucket.total + elapsed - bucket.selfMs = bucket.selfMs + selfDelta - bucket.descN = bucket.descN + cn - bucket.mem = bucket.mem + mDelta - if elapsed > bucket.max then bucket.max = elapsed end - - return r1, r2, r3, r4, r5 - end - - wrapped = wrapped + 1 - end - end - - -- ---------------------------------------------------------- - -- Wrap event dispatchers. Each wrapped dispatcher records into - -- self.eventData[eventName], indexed by the event name (the second - -- argument the dispatcher receives). One dispatcher feeds many - -- event names, so the bucket is created lazily on first occurrence. - -- ---------------------------------------------------------- - local eventData = self.eventData - local eventsWrapped = 0 - for _, dispatcher in ipairs(PROFILED_EVENT_DISPATCHERS) do - local container = dispatcher.container - local key = dispatcher.key - local original = container[key] - if type(original) == "function" then - self.eventOriginals[key] = { container = container, key = key, original = original } - local orig = original - local source = dispatcher.source - - container[key] = function(selfArg, event, ...) - -- Dispatchers sit at the root of nearly every call chain DF - -- runs, so their self time — the dispatch and routing work, - -- with the profiled methods they drive taken back out — is - -- the number that says whether routing itself is expensive. - local d = callDepth + 1 - callDepth = d - childMs[d] = 0 - childN[d] = 0 - - local m0 = collectgarbage("count") - local t0 = debugprofilestop() - local r1, r2, r3, r4, r5 = orig(selfArg, event, ...) - local elapsed = debugprofilestop() - t0 - local mDelta = collectgarbage("count") - m0 - if mDelta < 0 then mDelta = 0 end - - local ct, cn = childMs[d], childN[d] - callDepth = d - 1 - if d > 1 then - childMs[d - 1] = childMs[d - 1] + elapsed - childN[d - 1] = childN[d - 1] + cn + 1 - end - local selfDelta = elapsed - ct - if selfDelta < 0 then selfDelta = 0 end - - local bucket = eventData[event] - if not bucket then - bucket = { calls = 0, total = 0, selfMs = 0, descN = 0, - max = 0, mem = 0, source = source } - eventData[event] = bucket - end - bucket.calls = bucket.calls + 1 - bucket.total = bucket.total + elapsed - bucket.selfMs = bucket.selfMs + selfDelta - bucket.descN = bucket.descN + cn - bucket.mem = bucket.mem + mDelta - if elapsed > bucket.max then bucket.max = elapsed end - - return r1, r2, r3, r4, r5 - end - eventsWrapped = eventsWrapped + 1 - end - end - - -- ---------------------------------------------------------- - -- Wrap all currently-known DF OnUpdate handlers. The SetScript - -- hook has been recording these since addon load, so this catches - -- every handler installed before the profiler started. Handlers - -- added AFTER this point are wrapped on the spot by the same hook. - -- - -- Defense in depth: re-run IsDFFrame on each registry entry here. - -- The filter was added partway through the profiler's life, so the - -- registry may contain stale non-DF frames recorded before the - -- filter existed. Scrub them out instead of wrapping them, which - -- would cause taint errors in Blizzard's secure frame updates. - -- ---------------------------------------------------------- - local updatesWrapped = 0 - local toRemove - for frame, handler in pairs(onUpdateRegistry) do - if frame == tickFrame or frame == profilerFrame then - -- Don't profile our own machinery. tickFrame drives the spike counter; - -- profilerFrame is this window's own half-second refresh, and it is named - -- "DFProfilerFrame" so IsDFFrame matches it like any other DF frame. Left - -- in, the profiler's own UI showed up in its own OnUpdate table — and it - -- got busier the longer you watched, because the table it renders grows. - elseif not IsDFFrame(frame) then - -- Stale non-DF entry (recorded pre-filter). Drop it. - toRemove = toRemove or {} - toRemove[#toRemove + 1] = frame - else - WrapFrameOnUpdate(frame, handler) - updatesWrapped = updatesWrapped + 1 - end - end - if toRemove then - for i = 1, #toRemove do - onUpdateRegistry[toRemove[i]] = nil - end - end - - -- Start the per-tick OnUpdate that drives spike detection. - tickFrame:Show() - - DF:Say(format("Recording — %d/%d functions, %d events, %d OnUpdate handlers instrumented (wrapper overhead %sus/call, subtracted)", - wrapped, #PROFILED_FUNCTIONS, eventsWrapped, updatesWrapped, FormatUs(self.overheadMs))) - -- Naming them matters more than counting them: "91/94" tells you something - -- broke, the list tells you WHAT. A target goes missing when the function is - -- renamed or deleted and this table is not updated with it. - if #missing > 0 then - DF:Say("Profiler targets that did NOT resolve (not measured)", tostring(#missing), "BAD") - for _, path in ipairs(missing) do - print(" " .. DF.OUT.BAD .. path .. "|r") - end - end -end - -function Profiler:Stop() - if not self.active then return end - self.stopTime = debugprofilestop() - self.active = false - self.containerDelta = self:_ContainerStatsDelta() - - -- Restore originals on their actual container tables (supports dotted paths) - for _, entry in pairs(self.originals) do - entry.container[entry.key] = entry.original - end - for _, entry in pairs(self.eventOriginals) do - entry.container[entry.key] = entry.original - end - - -- Restore OnUpdate handlers. The installingOnUpdate guard suppresses - -- the SetScript hook so it doesn't immediately re-wrap them. - for frame, entry in pairs(onUpdateWrapped) do - installingOnUpdate = true - frame:SetScript("OnUpdate", entry.original) - installingOnUpdate = false - end - wipe(onUpdateWrapped) - - -- Stop the per-tick OnUpdate so profiler has zero idle cost when stopped. - -- That also stops the per-frame callDepth reset, so clear it here — the - -- next Start would otherwise inherit whatever an error left behind. - tickFrame:Hide() - callDepth = 0 - - DF:Say("Profiler stopped", FormatElapsed(self:GetElapsedSeconds()), "NEUTRAL") -end - -function Profiler:Reset() - for _, d in pairs(self.data) do - d.calls = 0 - d.total = 0 - d.selfMs = 0 - d.descN = 0 - d.max = 0 - d.mem = 0 - end - for _, ts in pairs(self.tickStats) do - ts.lastTick = -1 - ts.calls = 0 - ts.maxPerTick = 0 - end - -- Event + update buckets are created lazily so just wipe them. - wipe(self.eventData) - for _, d in pairs(self.updateData) do - d.calls = 0 - d.total = 0 - d.selfMs = 0 - d.descN = 0 - d.max = 0 - d.mem = 0 - end - callDepth = 0 - if self.active then - self.startTime = debugprofilestop() - self:_SnapshotContainerStats() - self.containerDelta = nil - end -end - --- ============================================================ --- CONTAINER LIFECYCLE COUNTERS --- DF.AuraContainer.stats counts builds / teardowns / combat-deferred --- rebuilds for the whole session. The profiler snapshots it on Start and --- reports the delta: on 12.1 the per-update aura cost lives game-side, so --- the number that matters is how often we RECREATE containers. Nonzero --- builds during steady-state combat = a structural-signature bug --- (rebuild storm), not normal operation. --- ============================================================ - -function Profiler:_SnapshotContainerStats() - local s = DF.AuraContainer and DF.AuraContainer.stats - if s then - self.containerStats0 = { builds = s.builds, teardowns = s.teardowns, defers = s.defers } - else - self.containerStats0 = nil - end -end - -function Profiler:_ContainerStatsDelta() - local s = DF.AuraContainer and DF.AuraContainer.stats - local s0 = self.containerStats0 - if not (s and s0) then return nil end - return { - builds = s.builds - s0.builds, - teardowns = s.teardowns - s0.teardowns, - defers = s.defers - s0.defers, - } -end - --- Delta for the current window: live while recording, frozen at Stop. -function Profiler:GetContainerDelta() - if self.active then return self:_ContainerStatsDelta() end - return self.containerDelta -end - -function Profiler:Toggle() - if self.active then self:Stop() else self:Start() end -end - -function Profiler:GetElapsedSeconds() - if self.startTime == 0 then return 0 end - local endTime = self.active and debugprofilestop() or self.stopTime - return (endTime - self.startTime) / 1000 -end - -local function GetActiveSource(self) - if self.viewMode == "events" then return self.eventData end - if self.viewMode == "updates" then return self.updateData end - return self.data -end - -function Profiler:GetTotalCalls() - local total = 0 - for _, d in pairs(GetActiveSource(self)) do total = total + d.calls end - return total -end - --- ============================================================ --- WHICH TOTAL IS A LEGITIMATE SUM --- ---------------------------------------------------------- --- ☠ Functions view: these rows NEST inside each other by design, so adding --- their inclusive times counts the same microsecond once per level. It is not --- a total of anything. Self time is the only honest sum here, and it is what --- the % column is a share of. --- --- Events / OnUpdate views: those rows do not nest — one event dispatch never --- runs inside another, and the C layer calls OnUpdate handlers one after the --- other rather than within each other. Inclusive is both a valid sum and the --- more useful reading there ("what did this event cost me, all in"), so those --- two views keep it. -local function ViewUsesSelf(mode) - return mode == "functions" -end - --- ⚠ Deliberately delegates rather than summing the buckets itself. A second --- aggregation is a second chance to disagree with the table the user is reading, --- and the two DID disagree. The extra sort costs nothing worth counting: this is a --- debug window refreshing twice a second, and UpdateUI calls GetSortedResults anyway. -function Profiler:GetGrandTotalMs() - local _, grand = self:GetSortedResults() - return grand or 0 -end - --- Display name suffixes for frame types -local TYPE_LABELS = { - ["|P"] = " [Party]", - ["|R"] = " [Raid]", - ["|HP"] = " [HL-P]", - ["|HR"] = " [HL-R]", - ["|?"] = "", -- no suffix for "other" (functions that don't take a frame) -} - -function Profiler:GetSortedResults() - if self.viewMode == "events" then - return self:GetSortedEventResults() - end - if self.viewMode == "updates" then - return self:GetSortedUpdateResults() - end - - local results = {} - local grandSelf = 0 - local tickStats = self.tickStats - local oh = self.overheadMs - - -- Max is a single-call inclusive figure, so exactly one wrapper's worth of - -- overhead is in it. Children nested under that one call carry theirs too - -- and cannot be separated out — this corrects what is knowable. - local function CorrectedMax(m) - local v = m - oh - return v > 0 and v or 0 - end - - if self.splitByFrame then - -- Split mode: one row per function+type combination (only those with calls) - for key, d in pairs(self.data) do - if d.calls > 0 then - local total, selfMs = Corrected(d.total, d.selfMs, d.calls, d.descN) - grandSelf = grandSelf + selfMs - -- Parse "funcName|type" into base name + suffix - local baseName, suffix = key:match("^(.+)(|.+)$") - if not baseName then - baseName = key - suffix = "" - end - local displayName = baseName .. (TYPE_LABELS[suffix] or suffix) - local ts = tickStats[baseName] - results[#results + 1] = { - name = displayName, - calls = d.calls, - total = total, - selfMs = selfMs, - avg = total / d.calls, - max = CorrectedMax(d.max), - -- Memory: bytes per call (KB delta * 1024 / calls) - mem = d.calls > 0 and (d.mem * 1024 / d.calls) or 0, - -- Peak/tick is per-function, not per-bucket; show the same - -- value on every split row for that function. - peak = ts and ts.maxPerTick or 0, - } - end - end - else - -- Aggregate mode: combine all frame types into one row per function. - -- ⚠ Sum the RAW fields and correct once at the end. Correcting each - -- bucket first and then adding would clamp each partial at zero - -- independently, so a function that is cheap in party and expensive in - -- raid would lose the party rows' negative headroom and read high. - local aggregated = {} - local aggOrder = {} -- preserve insertion order for deterministic iteration - for key, d in pairs(self.data) do - if d.calls > 0 then - local baseName = key:match("^(.+)|") or key - if not aggregated[baseName] then - aggregated[baseName] = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } - aggOrder[#aggOrder + 1] = baseName - end - local agg = aggregated[baseName] - agg.calls = agg.calls + d.calls - agg.total = agg.total + d.total - agg.selfMs = agg.selfMs + d.selfMs - agg.descN = agg.descN + d.descN - agg.mem = agg.mem + d.mem - if d.max > agg.max then agg.max = d.max end - end - end - - for _, baseName in ipairs(aggOrder) do - local agg = aggregated[baseName] - local total, selfMs = Corrected(agg.total, agg.selfMs, agg.calls, agg.descN) - grandSelf = grandSelf + selfMs - local ts = tickStats[baseName] - results[#results + 1] = { - name = baseName, - calls = agg.calls, - total = total, - selfMs = selfMs, - avg = total / agg.calls, - max = CorrectedMax(agg.max), - mem = agg.calls > 0 and (agg.mem * 1024 / agg.calls) or 0, - peak = ts and ts.maxPerTick or 0, - } - end - end - - -- Share of SELF time, not of the inclusive sum — see the ViewUsesSelf note. - -- These add up to 100%; the old inclusive shares did not add up to anything. - for _, r in ipairs(results) do - r.pct = grandSelf > 0 and (r.selfMs / grandSelf * 100) or 0 - end - local grandTotal = grandSelf - - local col = self.sortColumn - local desc = self.sortDesc - sort(results, function(a, b) - if col == "name" then - if desc then return a.name > b.name else return a.name < b.name end - end - if desc then return a[col] > b[col] else return a[col] < b[col] end - end) - - return results, grandTotal -end - --- Build sorted rows from OnUpdate handler data. Same shape as the --- function/event variants. Frame label is shortened to avoid blowing --- out the name column when GetDebugName returns a long parent chain. -function Profiler:GetSortedUpdateResults() - local results = {} - local grandTotal = 0 - - local oh = self.overheadMs - - for label, d in pairs(self.updateData) do - if d.calls > 0 then - local total, selfMs = Corrected(d.total, d.selfMs, d.calls, d.descN) - grandTotal = grandTotal + total - -- Trim very long parent-chain labels: keep the last segment. - local short = label - if #label > 36 then - local tail = label:match("([^%.]+)$") - short = tail and ("…" .. tail) or label:sub(-36) - end - local mx = d.max - oh - results[#results + 1] = { - name = short, - calls = d.calls, - total = total, - selfMs = selfMs, - avg = total / d.calls, - max = mx > 0 and mx or 0, - mem = d.calls > 0 and (d.mem * 1024 / d.calls) or 0, - peak = 0, - } - end - end - - for _, r in ipairs(results) do - r.pct = grandTotal > 0 and (r.total / grandTotal * 100) or 0 - end - - local col = self.sortColumn - local desc = self.sortDesc - sort(results, function(a, b) - if col == "name" then - if desc then return a.name > b.name else return a.name < b.name end - end - if desc then return a[col] > b[col] else return a[col] < b[col] end - end) - - return results, grandTotal -end - --- Build sorted rows from event dispatcher data. The shape matches the --- function results so the existing UI code can render either without a --- branch, except that the "peak" column is unused (always 0) for events --- and the "name" carries the event name plus a small source tag. -function Profiler:GetSortedEventResults() - local results = {} - local grandTotal = 0 - - local oh = self.overheadMs - - for eventName, d in pairs(self.eventData) do - if d.calls > 0 then - local total, selfMs = Corrected(d.total, d.selfMs, d.calls, d.descN) - grandTotal = grandTotal + total - local mx = d.max - oh - results[#results + 1] = { - name = eventName .. (d.source and (" [" .. d.source .. "]") or ""), - calls = d.calls, - total = total, - -- Self here = dispatch and routing only, with the profiled DF - -- methods this event drove subtracted back out. A big Total - -- next to a tiny Self means the event is expensive because of - -- what it triggers, not because routing it costs anything. - selfMs = selfMs, - avg = total / d.calls, - max = mx > 0 and mx or 0, - mem = d.calls > 0 and (d.mem * 1024 / d.calls) or 0, - peak = 0, - } - end - end - - for _, r in ipairs(results) do - r.pct = grandTotal > 0 and (r.total / grandTotal * 100) or 0 - end - - local col = self.sortColumn - local desc = self.sortDesc - sort(results, function(a, b) - if col == "name" then - if desc then return a.name > b.name else return a.name < b.name end - end - if desc then return a[col] > b[col] else return a[col] < b[col] end - end) - - return results, grandTotal -end - --- ============================================================ --- QUICK PROFILE (timed auto-run, prints to chat) --- ============================================================ - -function Profiler:QuickProfile(duration) - duration = duration or 10 - if self.active then self:Stop() end - - self:Start() - DF:Say("Profiler auto-stopping", duration .. "s", "NEUTRAL") - - C_Timer.After(duration, function() - if self.active then - self:Stop() - self:PrintResults() - if profilerFrame and profilerFrame:IsShown() then - UpdateUI() - end - end - end) -end - --- ============================================================ --- PRINT TO CHAT --- ============================================================ - -function Profiler:PrintResults() - local results, grandTotal = self:GetSortedResults() - local elapsed = self:GetElapsedSeconds() - local totalCalls = self:GetTotalCalls() - - if #results == 0 then - DF:Say("No data collected.") - return - end - - print(" ") - -- Name the denominator. In the functions view it is the sum of SELF times - -- (the only sum that doesn't count nested calls twice); in the other two, - -- where rows don't nest, it is the inclusive sum. - DF:Say(format("[%s] %s | %s calls | %sms %s CPU", - self.viewMode, FormatElapsed(elapsed), CommaNumber(totalCalls), FormatMs(grandTotal), - ViewUsesSelf(self.viewMode) and "self" or "inclusive")) - local cd = self:GetContainerDelta() - if cd then - -- Builds during steady-state combat = rebuild storm; see counter block above. - local warn = (cd.builds > 0) and "|cffffff88" or "|cff88ff88" - print(format(" %sAura containers: %d built, %d torn down, %d rebuilds deferred to combat end|r", - warn, cd.builds, cd.teardowns, cd.defers)) - end - print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") - - for i, r in ipairs(results) do - local color - if r.pct >= 25 then color = "|cffff6666" - elseif r.pct >= 10 then color = "|cffffff88" - else color = "|cff88ff88" end - - print(format(" %s%2d. %-36s|r %s calls %sms %sms self %sus avg %sus max pk %s %sB %s%5.1f%%|r", - color, i, r.name, - CommaNumber(r.calls), - FormatMs(r.total), - FormatMs(r.selfMs or 0), - FormatUs(r.avg), - FormatUs(r.max), - FormatPeak(r.peak), - FormatBytes(r.mem), - color, r.pct - )) - end - - print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") - print(" ") -end - --- ============================================================ --- SUMMARY (top-N across all three categories) --- ============================================================ - --- Helper: temporarily switch viewMode, run GetSortedResults, restore. --- Used by PrintSummary so it can collect results from each category --- without permanently flipping the user's UI view. -local function TopN(self, mode, n) - local prev = self.viewMode - self.viewMode = mode - local results, grand = self:GetSortedResults() - self.viewMode = prev - local out = {} - for i = 1, math.min(n, #results) do - out[i] = results[i] - end - return out, grand -end - -function Profiler:PrintSummary() - local elapsed = self:GetElapsedSeconds() - if elapsed <= 0 then - DF:Say("No data collected.") - return - end - - -- Aggregate totals across all three sources independently. Note - -- these will overlap (events drive functions which drive OnUpdate - -- ticks) — they're shown as separate lenses, not sums. - local _, funcGrand = TopN(self, "functions", 0) - local _, evtGrand = TopN(self, "events", 0) - local _, updGrand = TopN(self, "updates", 0) - - print(" ") - print(format("|cff00ff00DF Profiler Summary:|r %s elapsed", FormatElapsed(elapsed))) - print(format(" Functions: %sms SELF CPU across wrapped DF methods (nested calls counted once)", FormatMs(funcGrand))) - print(format(" Events: %sms inclusive CPU across event handlers", FormatMs(evtGrand))) - print(format(" OnUpdate: %sms inclusive CPU across every-frame handlers", FormatMs(updGrand))) - print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") - - local function dump(label, mode) - local rows = TopN(self, mode, 5) - if #rows == 0 then - print(format(" |cffaaaaaaTop 5 %s:|r (none)", label)) - return - end - print(format(" |cffffd700Top 5 %s:|r", label)) - for i, r in ipairs(rows) do - print(format(" %d. %-34s %s calls %sms %sms self %sus avg %s%%", - i, r.name, - CommaNumber(r.calls), - FormatMs(r.total), - FormatMs(r.selfMs or 0), - FormatUs(r.avg), - format("%.1f", r.pct))) - end - end - dump("Functions (by total ms)", "functions") - dump("Events (by total ms)", "events") - dump("OnUpdate (by total ms)", "updates") - - print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") - print(" ") -end - --- ============================================================ --- UI --- ============================================================ - -local ROW_HEIGHT = 18 -local MAX_ROWS = 30 -local FRAME_WIDTH = 786 -- +66 over the pre-Self-column width -local CONTENT_LEFT = 10 -local CONTENT_RIGHT = -10 -local HEADER_Y = -66 -local DATA_START_Y = -86 - --- Column layout -local COLUMNS = { - { key = "name", label = "Function", width = 210, align = "LEFT" }, - { key = "calls", label = "Calls", width = 54, align = "RIGHT" }, - { key = "total", label = "Total ms", width = 60, align = "RIGHT" }, - -- Self = Total minus the time spent inside OTHER profiled functions this - -- one called. Sort by this to find where the work actually happens; sort - -- by Total to find which entry point owns a whole subtree. - { key = "selfMs", label = "Self ms", width = 62, align = "RIGHT" }, - { key = "avg", label = "Avg us", width = 52, align = "RIGHT" }, - { key = "max", label = "Max us", width = 52, align = "RIGHT" }, - { key = "peak", label = "Peak/tk", width = 52, align = "RIGHT" }, - { key = "mem", label = "Bytes", width = 56, align = "RIGHT" }, - { key = "pct", label = "%", width = 46, align = "RIGHT" }, -} - -local CONTENT_WIDTH = 0 -for _, col in ipairs(COLUMNS) do - CONTENT_WIDTH = CONTENT_WIDTH + col.width + 4 -end - -local function UpdateColumnHeaders() - for _, col in ipairs(COLUMNS) do - local label = col.label - if Profiler.sortColumn == col.key then - label = label .. (Profiler.sortDesc and " v" or " ^") - end - if headerTexts[col.key] then - headerTexts[col.key]:SetText(label) - end - end -end - -local function CreateRow(parent, index) - local row = CreateFrame("Frame", nil, parent) - row:SetHeight(ROW_HEIGHT) - row:SetPoint("LEFT", parent, "LEFT", CONTENT_LEFT, 0) - row:SetPoint("RIGHT", parent, "RIGHT", CONTENT_RIGHT, 0) - - -- Alternating background - row.bg = row:CreateTexture(nil, "BACKGROUND", nil, 0) - row.bg:SetAllPoints() - row.bg:SetColorTexture(index % 2 == 0 and 0.13 or 0.08, index % 2 == 0 and 0.13 or 0.08, index % 2 == 0 and 0.13 or 0.08, 1) - - -- Percentage bar (visual indicator behind text) - row.pctBar = row:CreateTexture(nil, "BACKGROUND", nil, 1) - row.pctBar:SetPoint("LEFT") - row.pctBar:SetHeight(ROW_HEIGHT) - row.pctBar:SetWidth(1) - row.pctBar:Hide() - - -- Column font strings - row.cols = {} - local xOffset = 2 - for _, col in ipairs(COLUMNS) do - local fs = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - fs:SetJustifyH(col.align) - fs:SetPoint("LEFT", row, "LEFT", xOffset, 0) - fs:SetWidth(col.width) - row.cols[col.key] = fs - xOffset = xOffset + col.width + 4 - end - - row:Hide() - return row -end - --- Assign to the forward-declared upvalue so the combat handler can call it -UpdateUI = function() - if not profilerFrame or not profilerFrame:IsShown() then return end - - local elapsed = Profiler:GetElapsedSeconds() - local totalCalls = Profiler:GetTotalCalls() - local grandTotal = Profiler:GetGrandTotalMs() - - -- Status line - if Profiler.active then - profilerFrame.statusText:SetText(format( - "|cff00ff00Recording|r %s | %s calls | %sms CPU", - FormatElapsed(elapsed), CommaNumber(totalCalls), FormatMs(grandTotal) - )) - profilerFrame.toggleBtn:SetText("Stop") - else - if totalCalls > 0 then - local combatTag = Profiler.combatAuto and " | |cff00ff00Combat Armed|r" or "" - profilerFrame.statusText:SetText(format( - "|cffff4444Stopped|r %s | %s calls | %sms CPU%s", - FormatElapsed(elapsed), CommaNumber(totalCalls), FormatMs(grandTotal), combatTag - )) - else - local combatTag = Profiler.combatAuto and "|cff00ff00Combat Armed|r Waiting for combat..." or "|cff888888Ready|r Press Start to begin profiling" - profilerFrame.statusText:SetText(combatTag) - end - profilerFrame.toggleBtn:SetText("Start") - end - - -- Data rows - local results = Profiler:GetSortedResults() - - for i = 1, MAX_ROWS do - local row = dataRows[i] - if not row then break end - - local r = results[i] - if r then - -- Color based on cost share - local cr, cg, cb - local barR, barG, barB - if r.pct >= 25 then - cr, cg, cb = 1, 0.5, 0.5 - barR, barG, barB = 0.5, 0.15, 0.15 - elseif r.pct >= 10 then - cr, cg, cb = 1, 1, 0.6 - barR, barG, barB = 0.4, 0.4, 0.1 - else - cr, cg, cb = 0.7, 0.9, 0.7 - barR, barG, barB = 0.15, 0.35, 0.15 - end - - -- Percentage bar width - local barWidth = max(1, (CONTENT_WIDTH - 4) * (r.pct / 100)) - row.pctBar:SetWidth(barWidth) - row.pctBar:SetColorTexture(barR, barG, barB, 0.35) - row.pctBar:Show() - - row.cols.name:SetText(r.name) - row.cols.name:SetTextColor(cr, cg, cb) - - row.cols.calls:SetText(CommaNumber(r.calls)) - row.cols.calls:SetTextColor(0.8, 0.8, 0.8) - - row.cols.total:SetText(FormatMs(r.total)) - row.cols.total:SetTextColor(0.8, 0.8, 0.8) - - -- Self is the ranked figure, so it reads brighter than Total. - row.cols.selfMs:SetText(FormatMs(r.selfMs or 0)) - row.cols.selfMs:SetTextColor(0.95, 0.95, 0.8) - - row.cols.avg:SetText(FormatUs(r.avg)) - row.cols.avg:SetTextColor(0.7, 0.7, 0.7) - - row.cols.max:SetText(FormatUs(r.max)) - row.cols.max:SetTextColor(0.7, 0.7, 0.7) - - row.cols.peak:SetText(FormatPeak(r.peak)) - -- Tint Peak/tick yellow when it's high (>=20 calls in one frame) - -- — that's the cascade signal we're hunting. - if r.peak >= 20 then - row.cols.peak:SetTextColor(1, 0.85, 0.4) - else - row.cols.peak:SetTextColor(0.7, 0.7, 0.7) - end - - row.cols.mem:SetText(FormatBytes(r.mem)) - -- Tint Mem yellow when bytes/call >= 256, red when >= 1024 - if r.mem >= 1024 then - row.cols.mem:SetTextColor(1, 0.5, 0.5) - elseif r.mem >= 256 then - row.cols.mem:SetTextColor(1, 0.85, 0.4) - else - row.cols.mem:SetTextColor(0.7, 0.7, 0.7) - end - - row.cols.pct:SetText(format("%.1f%%", r.pct)) - row.cols.pct:SetTextColor(cr, cg, cb) - - row:Show() - else - row:Hide() - end - end - - -- Keep combat button text in sync - if profilerFrame.combatBtn then - if Profiler.combatAuto then - profilerFrame.combatBtn:SetText("|cff00ff00Combat|r") - else - profilerFrame.combatBtn:SetText("Combat") - end - end - - -- Keep split button text in sync - if profilerFrame.splitBtn then - if Profiler.splitByFrame then - profilerFrame.splitBtn:SetText("|cff00ff00Split|r") - else - profilerFrame.splitBtn:SetText("Split") - end - end - - -- Keep view button text in sync - if profilerFrame.viewBtn then - if Profiler.viewMode == "events" then - profilerFrame.viewBtn:SetText("|cff00ff00Events|r") - elseif Profiler.viewMode == "updates" then - profilerFrame.viewBtn:SetText("|cff00ff00OnUpdate|r") - else - profilerFrame.viewBtn:SetText("Functions") - end - end -end - -function Profiler:CreateUI() - if profilerFrame then - profilerFrame:Show() - return - end - - -- ☠ This window is built from settings-panel widgets -- CreateInfoBanner in - -- particular, which parents the hook checkbox, so there is nothing sensible - -- to fall back to if it is missing. The profiler is opened deliberately, so - -- pulling the companion in here costs nothing anyone will notice and keeps - -- the panel whole. Same call the settings panel itself uses. - if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then - return - end - - -- Main frame - local f = CreateFrame("Frame", "DFProfilerFrame", UIParent, "BackdropTemplate") - -- Bottom slack has to clear TWO stacked things, not one: - -- 6 banner offset from the frame bottom - -- 34 the banner's minimum height - -- 6 gap - -- 24 the column legend that sits above it (TWO lines since Self landed) - -- 10 breathing room before the last data row - -- The old value was 30, which did not even clear the 28px strip that used to - -- live there — a full 30-row table clipped its last row behind it, and the - -- legend was underneath the strip entirely. - f:SetSize(FRAME_WIDTH, DATA_START_Y * -1 + MAX_ROWS * ROW_HEIGHT + 80) - f:SetPoint("CENTER", 0, 50) - -- Panel chrome, same as every other DF window. Was a bespoke plate from three - -- hand-mixed literals, which is the other half of why this window read as - -- foreign next to the settings frame. - DF.GUI:CreatePanelBackdrop(f) - f:SetFrameStrata("HIGH") - f:SetMovable(true) - f:EnableMouse(true) - f:RegisterForDrag("LeftButton") - f:SetScript("OnDragStart", f.StartMoving) - f:SetScript("OnDragStop", f.StopMovingOrSizing) - f:SetClampedToScreen(true) - profilerFrame = f - - -- Title. The "DF" used to be hardcoded bright green, which matched nothing - -- else in the addon; the theme accent is what every section header uses and - -- it follows the party/raid pole like the rest of the GUI. - local title = f:CreateFontString(nil, "OVERLAY", "DFFontNormalLarge") - title:SetPoint("TOPLEFT", 12, -10) - title:SetText("Profiler") - do - local tc = DF.GUI.GetThemeColor and DF.GUI.GetThemeColor() - if tc then title:SetTextColor(tc.r, tc.g, tc.b) end - end - - -- Close button. Was Blizzard's raw UI-Panel-MinimizeButton textures, which is - -- why this window's X had chrome no other DF surface has. - local closeBtn = DF.GUI:CreateCloseButton(f, { - onClick = function() f:Hide() end, - tooltip = "Close", - }) - closeBtn:SetPoint("TOPRIGHT", -6, -6) - - -- Status line - f.statusText = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - f.statusText:SetPoint("TOPLEFT", 12, -32) - f.statusText:SetJustifyH("LEFT") - f.statusText:SetText("|cff888888Ready|r Press Start to begin profiling") - - -- Buttons - local btnY = -46 - local btnH = 20 - local btnW = 68 - - f.toggleBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - DF.GUI:StyleButton(f.toggleBtn, { width = btnW, height = btnH, text = "Start" }) - f.toggleBtn:SetPoint("TOPLEFT", 10, btnY) - f.toggleBtn:SetScript("OnClick", function() - Profiler:Toggle() - UpdateUI() - UpdateColumnHeaders() - end) - - local resetBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - DF.GUI:StyleButton(resetBtn, { width = btnW, height = btnH, text = "Reset" }) - resetBtn:SetPoint("LEFT", f.toggleBtn, "RIGHT", 4, 0) - resetBtn:SetScript("OnClick", function() - Profiler:Reset() - UpdateUI() - end) - - local printBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - DF.GUI:StyleButton(printBtn, { width = 88, height = btnH, text = "Print to Chat" }) - printBtn:SetPoint("LEFT", resetBtn, "RIGHT", 4, 0) - printBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") - printBtn:SetScript("OnClick", function(self, button) - if button == "RightButton" then - Profiler:PrintSummary() - else - Profiler:PrintResults() - end - end) - printBtn:SetScript("OnEnter", function(self) - DF.GUI:ShowTooltip(self, { - title = "Print to Chat", - lines = { - "Left-click: dump the current view (functions/events/onupdate)", - "Right-click: print Top 5 across all categories (summary)", - }, - }) - end) - printBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) - - -- Custom duration input box - local durationInput = CreateFrame("EditBox", nil, f, "BackdropTemplate") - durationInput:SetSize(36, btnH) - durationInput:SetPoint("LEFT", printBtn, "RIGHT", 12, 0) - DF.GUI:CreateElementBackdrop(durationInput, { - bgColor = { 0.1, 0.1, 0.1, 1 }, - borderColor = { 0.4, 0.4, 0.4, 1 }, - }) - durationInput:SetFontObject(DFFontHighlightSmall) - durationInput:SetJustifyH("CENTER") - durationInput:SetAutoFocus(false) - durationInput:SetNumeric(true) - durationInput:SetMaxLetters(4) - durationInput:SetText("30") - -- Allow clicking away to clear focus - durationInput:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) - durationInput:SetScript("OnEnterPressed", function(self) - self:ClearFocus() - local dur = tonumber(self:GetText()) or 30 - if dur < 1 then dur = 1 end - Profiler:QuickProfile(dur) - UpdateUI() - UpdateColumnHeaders() - end) - - -- "s Run" button (triggers timed profile with input value) - local runBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - DF.GUI:StyleButton(runBtn, { width = 48, height = btnH, text = "s Run" }) - runBtn:SetPoint("LEFT", durationInput, "RIGHT", 2, 0) - runBtn:SetScript("OnClick", function() - durationInput:ClearFocus() - local dur = tonumber(durationInput:GetText()) or 30 - if dur < 1 then dur = 1 end - Profiler:QuickProfile(dur) - UpdateUI() - UpdateColumnHeaders() - end) - - -- Combat Auto toggle button - f.combatBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - -- text = "" so StyleButton creates AND registers its fontstring; the - -- label itself is set by the Update*BtnText below, which needs it to exist. - DF.GUI:StyleButton(f.combatBtn, { width = 78, height = btnH, text = "" }) - f.combatBtn:SetPoint("LEFT", runBtn, "RIGHT", 8, 0) - local function UpdateCombatBtnText() - if Profiler.combatAuto then - f.combatBtn:SetText("|cff00ff00Combat|r") - else - f.combatBtn:SetText("Combat") - end - end - f.combatBtn:SetScript("OnClick", function() - Profiler:SetCombatAuto(not Profiler.combatAuto) - UpdateCombatBtnText() - UpdateUI() - end) - f.combatBtn:SetScript("OnEnter", function(self) - DF.GUI:ShowTooltip(self, { - title = "Combat Auto-Profile", - lines = { Profiler.combatAuto - and { text = "ON: Profiling starts on combat, stops + prints on combat end.", - color = { 0, 1, 0 } } - or "OFF: Click to enable automatic combat profiling." }, - }) - end) - f.combatBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) - UpdateCombatBtnText() - - -- View cycle button (Functions / Events / OnUpdate). Top-right, left of Split. - f.viewBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - -- text = "" so StyleButton creates AND registers its fontstring; the - -- label itself is set by the Update*BtnText below, which needs it to exist. - DF.GUI:StyleButton(f.viewBtn, { width = 82, height = btnH, text = "" }) - f.viewBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -84, btnY) - local VIEW_LABELS = { - functions = "Functions", - events = "|cff00ff00Events|r", - updates = "|cff00ff00OnUpdate|r", - } - local VIEW_NEXT = { - functions = "events", - events = "updates", - updates = "functions", - } - local function UpdateViewBtnText() - f.viewBtn:SetText(VIEW_LABELS[Profiler.viewMode] or "Functions") - end - f.viewBtn:SetScript("OnClick", function() - Profiler.viewMode = VIEW_NEXT[Profiler.viewMode] or "functions" - UpdateViewBtnText() - UpdateUI() - UpdateColumnHeaders() - end) - f.viewBtn:SetScript("OnEnter", function(self) - DF.GUI:ShowTooltip(self, { - title = "View Mode", - lines = { - "Click to cycle: Functions → Events → OnUpdate.", - "Functions: time per DF method", - "Events: time per WoW event (UNIT_AURA, etc.)", - "OnUpdate: time per OnUpdate handler (every-frame ticks)", - }, - }) - end) - f.viewBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) - UpdateViewBtnText() - - -- Split by Frame Type toggle button - f.splitBtn = CreateFrame("Button", nil, f, "BackdropTemplate") - -- text = "" so StyleButton creates AND registers its fontstring; the - -- label itself is set by the Update*BtnText below, which needs it to exist. - DF.GUI:StyleButton(f.splitBtn, { width = 50, height = btnH, text = "" }) - f.splitBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -28, btnY) - local function UpdateSplitBtnText() - if Profiler.splitByFrame then - f.splitBtn:SetText("|cff00ff00Split|r") - else - f.splitBtn:SetText("Split") - end - end - f.splitBtn:SetScript("OnClick", function() - Profiler.splitByFrame = not Profiler.splitByFrame - UpdateSplitBtnText() - UpdateUI() - end) - f.splitBtn:SetScript("OnEnter", function(self) - DF.GUI:ShowTooltip(self, { - title = "Split by Frame Type", - lines = { Profiler.splitByFrame - and { text = "ON: Showing per-type breakdown (Party, Raid, HL-Party, HL-Raid).", - color = { 0, 1, 0 } } - or "OFF: Click to split results by frame type." }, - }) - end) - f.splitBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) - UpdateSplitBtnText() - - -- ============================================================ - -- ONUPDATE TRACKING STRIP (bottom of the window, above the rows) - -- ============================================================ - -- What the toggle actually controls: a hooksecurefunc on the Frame - -- metatable's SetScript (see the top of this file), which registers every - -- OnUpdate handler a DF frame installs. That registry IS the OnUpdate tab — - -- with the hook off, the tab has no data source and is simply empty. - -- - -- ☠ It can only be installed at FILE-LOAD time: the hook has to be in place - -- before frames install their handlers, or everything set up during login is - -- invisible. Hence /rl, and hence the setting living in DandersFramesDB_v2 - -- (account SavedVariables) rather than a profile — it must be readable - -- before profiles initialise. - -- - -- ⚠ FORWARD-DECLARED. The checkbox's OnClick closure below calls this, and - -- the closure is built BEFORE the definition. Without this line the name - -- resolves as a global at closure-creation time, reads nil, and clicking the - -- box back to its current state throws "attempt to call a nil value" — the - -- same out-of-scope-local trap that left 8 dead branches in ColorPicker. - local UpdateHookBanner - - -- The real banner system, not a lookalike. This was a hand-built frame with - -- two hand-mixed colour literals approximating the "caution" tone; SetTone - -- gives the exact palette plus the matching icon, and cannot drift from the - -- banners on the settings pages. - -- - -- CreateInfoBanner self-measures its height and calls GUI:RelayoutHost. That - -- is a no-op here — RelayoutHost only does work for a widget inside a - -- settingsGroup, and walks the parent chain looking for a RefreshStates host - -- it will never find on a floating window — so the banner simply sizes - -- itself against the two anchors below. - local hookBanner = DF.GUI:CreateInfoBanner(f, { tone = "caution" }) - hookBanner:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 10, 6) - hookBanner:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 6) - f.hookBanner = hookBanner - - local hookCheckbox = CreateFrame("CheckButton", nil, hookBanner, "BackdropTemplate") - hookCheckbox:SetPoint("RIGHT", -6, 0) - DF.GUI:StyleCheckButton(hookCheckbox) - hookCheckbox:SetChecked(self.onUpdateHookEnabled) - hookCheckbox:SetScript("OnClick", function(cb) - if not DandersFramesDB_v2 then DandersFramesDB_v2 = {} end - local newState = cb:GetChecked() - DandersFramesDB_v2.profilerOnUpdateHook = newState - if newState == self.onUpdateHookEnabled then - -- Toggled back to what is already live — no reload needed. - UpdateHookBanner() - else - hookBanner:SetTone("caution") - hookBanner:SetText(newState - and "OnUpdate tracking will be ON after /rl." - or "OnUpdate tracking will be OFF after /rl.") - end - end) - hookCheckbox:SetScript("OnEnter", function(cb) - DF.GUI:ShowTooltip(cb, { - title = "Track OnUpdate handlers", - lines = { - "Hooks SetScript so the profiler can see every OnUpdate a DF frame installs. This is the data source for the OnUpdate tab — with it off, that tab is empty.", - "Applies at load only, so a /rl is required either way.", - }, - }) - end) - hookCheckbox:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) - - local hookLabel = hookBanner:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - hookLabel:SetPoint("RIGHT", hookCheckbox, "LEFT", -2, 0) - hookLabel:SetText("Track OnUpdate") - - -- Keep the banner's body clear of the toggle. The factory anchors the body - -- TOPLEFT-of-icon and RIGHT-of-banner; re-issue BOTH points rather than just - -- the right one, so there is no question of a stale anchor surviving. - hookBanner.body:ClearAllPoints() - hookBanner.body:SetPoint("TOPLEFT", hookBanner.icon, "TOPRIGHT", 8, -5) - hookBanner.body:SetPoint("RIGHT", hookLabel, "LEFT", -10, 0) - - -- ☠ The strip stays VISIBLE in both states. It used to Hide() itself once - -- the hook was on — and the checkbox lives inside it, so the only way to - -- turn tracking back off was the /df debug profiler hook command. A toggle - -- you can reach in one direction only is not a toggle. - function UpdateHookBanner() - if self.onUpdateHookEnabled then - hookBanner:SetTone("info") - hookBanner:SetText("OnUpdate tracking is on.") - else - hookBanner:SetTone("caution") - hookBanner:SetText("OnUpdate tracking is off — the OnUpdate tab will be empty.") - end - hookCheckbox:SetChecked(DandersFramesDB_v2 and DandersFramesDB_v2.profilerOnUpdateHook or false) - hookBanner:Show() - end - UpdateHookBanner() - - -- Column headers - local xOffset = CONTENT_LEFT + 2 - for _, col in ipairs(COLUMNS) do - local hdr = CreateFrame("Button", nil, f) - hdr:SetHeight(18) - hdr:SetPoint("TOPLEFT", f, "TOPLEFT", xOffset, HEADER_Y) - hdr:SetWidth(col.width) - - local text = hdr:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") - text:SetAllPoints() - text:SetJustifyH(col.align) - text:SetTextColor(0.5, 0.75, 1.0) - - local label = col.label - if Profiler.sortColumn == col.key then - label = label .. (Profiler.sortDesc and " v" or " ^") - end - text:SetText(label) - headerTexts[col.key] = text - - -- Click to sort - local colKey = col.key - hdr:SetScript("OnClick", function() - if Profiler.sortColumn == colKey then - Profiler.sortDesc = not Profiler.sortDesc - else - Profiler.sortColumn = colKey - Profiler.sortDesc = true - end - UpdateColumnHeaders() - UpdateUI() - end) - hdr:SetScript("OnEnter", function() text:SetTextColor(0.8, 1.0, 1.0) end) - hdr:SetScript("OnLeave", function() text:SetTextColor(0.5, 0.75, 1.0) end) - - xOffset = xOffset + col.width + 4 - end - - -- Header divider - local divider = f:CreateTexture(nil, "ARTWORK") - divider:SetColorTexture(0.3, 0.3, 0.3, 0.6) - divider:SetHeight(1) - divider:SetPoint("TOPLEFT", f, "TOPLEFT", CONTENT_LEFT, HEADER_Y - 18) - divider:SetPoint("TOPRIGHT", f, "TOPRIGHT", CONTENT_RIGHT, HEADER_Y - 18) - - -- Data rows - for i = 1, MAX_ROWS do - local row = CreateRow(f, i) - row:SetPoint("TOP", f, "TOP", 0, DATA_START_Y - (i - 1) * ROW_HEIGHT) - dataRows[i] = row - end - - -- Column legend, stacked ABOVE the hook banner. - -- ☠ It used to be anchored to the frame's own bottom at y=8 — which is inside - -- the banner's rect. That was survivable only while the banner HID itself - -- whenever tracking was on; now that the banner is always visible (so the - -- toggle is reachable in both directions) the legend sat behind it - -- permanently, bleeding through as ghost text. Anchor to the banner instead - -- of to the frame, so it also tracks the banner if its text ever wraps. - local infoLabel = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - infoLabel:SetPoint("BOTTOMLEFT", f.hookBanner, "TOPLEFT", 2, 6) - infoLabel:SetTextColor(0.4, 0.4, 0.4) - infoLabel:SetJustifyH("LEFT") - -- Two lines. The first says what the numbers ARE, which matters because - -- Total and Self answer different questions and only one of them adds up. - infoLabel:SetText( - "Total = inclusive (counts nested profiled calls) | Self = excludes them — this is what % ranks, and % sums to 100\n" .. - "Peak/tk = max calls in one frame | Bytes = avg alloc per call | wrapper overhead subtracted; a row cheaper than it reads 0.00, not below zero") - - -- Live refresh via OnUpdate - f.elapsed = 0 - f:SetScript("OnUpdate", function(self, elapsed) - self.elapsed = self.elapsed + elapsed - if self.elapsed < 0.5 then return end - self.elapsed = 0 - UpdateUI() - end) - - UpdateUI() -end - -function Profiler:ToggleUI() - if profilerFrame and profilerFrame:IsShown() then - profilerFrame:Hide() - else - self:CreateUI() - end -end +local addonName, DF = ... + +-- ============================================================ +-- DANDERSFRAMES FUNCTION PROFILER +-- +-- Zero overhead when disabled: uses function-swapping to wrap +-- DF:Method() calls with debugprofilestop() timing. When stopped, +-- original functions are fully restored — no runtime checks, +-- no wrappers, no cost. +-- +-- Two things make the numbers trustworthy rather than merely indicative: +-- * Self time. Profiled functions call other profiled functions, so every +-- Total contains its children. Self strips them back out, and it is what +-- the % column ranks — see the CALL STACK block below. +-- * Overhead subtraction. The wrapper's own in-window cost is measured on +-- the machine at Start and removed from every figure, so a function +-- called 200,000 times is not penalised against one called 200. +-- Both are corrections, not guarantees; the residuals are documented at +-- CalibrateOverhead. +-- +-- Usage: +-- /df debug profiler Toggle the profiler UI +-- /df debug profile [seconds] Quick run for N seconds (default 10) +-- ============================================================ + +local debugprofilestop = debugprofilestop +local collectgarbage = collectgarbage +local format = string.format +local sort = table.sort +local floor = math.floor +local max = math.max +local wipe = wipe +local pairs = pairs +local ipairs = ipairs +local tostring = tostring +local CreateFrame = CreateFrame + +-- Shared tick counter, incremented once per OnUpdate frame. +-- Wrapped functions read this via upvalue to detect "new tick" without +-- needing access to any timer state. +local tickRef = { 0 } + +-- ============================================================ +-- CALL STACK (for exclusive / "self" time) +-- ---------------------------------------------------------- +-- Every wrapper is a stack frame. On the way in it pushes a slot; on the +-- way out it adds its own inclusive time and call count into its PARENT's +-- slot. Self time is then just `elapsed - childMs[myDepth]`. +-- +-- Why this matters: wrapped functions call other wrapped functions — +-- UpdateAllAuras -> UpdateAuras_Enhanced, and UpdateTextDesigner -> +-- Render.UpdateFrame -> Resolver.Resolve three deep by design. Every level's +-- `total` contains its children, so summing the rows counted the same +-- microsecond two and three times and the % column was a share of an +-- invented denominator. Self time sums correctly; inclusive time does not. +-- +-- Only WRAPPED children are subtracted, which is exactly right: time in an +-- unwrapped local helper genuinely belongs to the function that called it. +-- +-- ☠ callDepth must never be allowed to ratchet. A Lua error inside a +-- profiled function unwinds past the decrement, leaving the counter high +-- forever and every subsequent call recorded as somebody's child. The tick +-- driver below resets it every frame, which is safe because nothing here +-- yields: no DF call is ever still on the stack when a new frame starts. +local callDepth = 0 +local childMs = {} -- [depth] = inclusive ms of wrapped children at that depth +local childN = {} -- [depth] = count of wrapped calls beneath that depth + +-- ============================================================ +-- STATE +-- ============================================================ + +local Profiler = { + active = false, + startTime = 0, -- debugprofilestop() ms when started + stopTime = 0, -- debugprofilestop() ms when stopped (for accurate elapsed) + combatAuto = false, -- auto start/stop on combat enter/leave + splitByFrame = false, -- show per-frame-type breakdown + viewMode = "functions", -- "functions" | "events" + overheadMs = 0, -- measured cost the wrapper adds to each call (ms) + data = {}, -- [funcName|type] = { calls, total, selfMs, descN, max, mem } + tickStats = {}, -- [funcName] = { lastTick, calls, maxPerTick } + originals = {}, -- [funcPath] = { table, key, original } + eventData = {}, -- [eventName] = { calls, total, max, mem, source } + eventOriginals = {}, -- [dispatcherKey] = { container, key, original } + updateData = {}, -- [frameLabel] = { calls, total, max, mem } + sortColumn = "total", + sortDesc = true, +} + +DF.Profiler = Profiler + +-- The profiler WINDOW lives in the load-on-demand companion +-- (Debug/ProfilerUI.lua); this file keeps the hooks, recording engine and the +-- chat reports, which must be resident. The window publishes two things back: +-- Profiler.frame the window frame (nil until first opened) +-- Profiler.UpdateUI its refresh function (nil until the companion loads) +-- Every read below is nil-guarded, and nil means the honest thing: no window +-- exists, so there is nothing to refresh or exclude. + +-- Combat auto-profile event frame (created once, persists) +local combatFrame = CreateFrame("Frame") +combatFrame:Hide() + +-- Per-tick driver: increments tickRef[1] every OnUpdate frame so wrapped +-- functions can detect "new tick" and aggregate per-tick call counts. +-- Hidden when profiler is stopped (no OnUpdate runs). +local tickFrame = CreateFrame("Frame") +tickFrame:Hide() +tickFrame:SetScript("OnUpdate", function() + tickRef[1] = tickRef[1] + 1 + -- Self-heal the call-stack depth (see the ☠ note on callDepth). A clean + -- frame always ends at 0, so this is a no-op except after an error. + callDepth = 0 +end) + +-- ============================================================ +-- ONUPDATE REGISTRY + SETSCRIPT HOOK +-- ---------------------------------------------------------- +-- Tracking OnUpdate handlers requires a different mechanism than +-- function or event wrapping. OnUpdate scripts are bound directly to +-- frames via SetScript, so there's no DF-owned function we can swap. +-- +-- Strategy: hook Frame:SetScript at addon load. Whenever a *DF-owned* +-- frame installs/removes an OnUpdate handler, we record the latest +-- handler in a registry. The registry alone has effectively zero +-- overhead — just one table assignment per SetScript call. No wrapping, +-- no instrumentation. +-- +-- On Profiler:Start, we walk the registry and replace each recorded +-- handler with a wrapped version that records timing + memory. New +-- OnUpdate handlers installed *after* Start are wrapped on the spot +-- by the same hook. On Stop, all originals are restored. +-- +-- The `installingOnUpdate` guard prevents the hook from re-wrapping +-- our own wrapper as we install it (otherwise infinite recursion). +-- +-- CRITICAL: we must filter to DF-owned frames only. If we wrap a +-- non-DF frame's OnUpdate (especially Blizzard's CompactRaidFrame +-- secure frames), our wrapper closure runs inside that frame's +-- update cascade and the execution context gets marked as +-- "tainted by DandersFrames". That taint propagates to every +-- UnitIsConnected / UnitHealthMax / etc. secret-value return and +-- every protected Show()/Hide() call downstream — breaking secure +-- Blizzard code and producing ADDON_ACTION_BLOCKED errors. This was +-- the cause of a raid-session bug report on 2026-04-08. +-- +-- The filter walks the frame's parent chain looking for any ancestor +-- whose name starts with "Danders" or "DF" — DF's named containers +-- and headers all use these prefixes (DandersPartyHeader, +-- DandersArenaHeader, DandersRaidGroupN, DFTestHeader, etc.). If no +-- ancestor has a DF name, the frame is foreign and must be skipped. +-- ============================================================ + +local onUpdateRegistry = {} -- [frame] = handler ref currently bound (or nil) +local onUpdateLabels = {} -- [frame] = stable label for display +local onUpdateDFCheck = {} -- [frame] = true/false cached IsDFFrame result +local onUpdateWrapped = {} -- [frame] = { original, stats } (only while active) +local installingOnUpdate = false -- re-entry guard for the SetScript hook + +-- Is this frame owned by DandersFrames? Walks up the parent chain +-- looking for an ancestor whose name starts with "Danders" or "DF". +-- DF's secure headers and containers are all explicitly named with +-- one of these prefixes (see Frames/Headers.lua). Anonymous DF child +-- frames inherit their DF-ness from a named ancestor, so the walk +-- catches them too. +-- +-- Results are cached per-frame because this is called from the +-- SetScript hook on every OnUpdate (re)bind and we don't want to +-- re-walk the parent chain every time. +local function IsDFFrame(frame) + local cached = onUpdateDFCheck[frame] + if cached ~= nil then return cached end + + local walker = frame + local depth = 0 -- safety cap against pathological cycles + while walker and depth < 32 do + -- pcall because some Blizzard template frames (e.g. RadialWheel + -- wedge buttons) error on :GetName() with "bad self". + local ok, name = pcall(walker.GetName, walker) + if ok and name then + local p2 = name:sub(1, 2) + local p7 = name:sub(1, 7) + if p7 == "Danders" or p2 == "DF" then + onUpdateDFCheck[frame] = true + return true + end + end + local ok2, parent = pcall(walker.GetParent, walker) + walker = (ok2 and parent) or nil + depth = depth + 1 + end + + onUpdateDFCheck[frame] = false + return false +end + +-- Best-effort label for a frame. Uses GetDebugName() when available +-- (modern WoW gives a parent-chain path), falls back to GetName(), +-- finally a generic "". Captured once at first sighting so it +-- stays stable across renames. +local function ResolveFrameLabel(frame) + local existing = onUpdateLabels[frame] + if existing then return existing end + local label + if frame.GetDebugName then + label = frame:GetDebugName() + end + if (not label or label == "") and frame.GetName then + label = frame:GetName() + end + if not label or label == "" then + label = "" + end + onUpdateLabels[frame] = label + return label +end + +-- Forward declaration so the SetScript hook can call WrapFrameOnUpdate +-- before it's defined further down (it lives inside Profiler:Start's +-- closure scope and needs to share state with this hook). +local WrapFrameOnUpdate + +-- ============================================================ +-- ONUPDATE TRACKING HOOK +-- ============================================================ +-- ☠ SAVEDVARIABLES ARE NOT LOADED WHEN THIS FILE EXECUTES. +-- This used to read DandersFramesDB_v2.profilerOnUpdateHook right here, at file +-- scope, and install the hook only if it was true. The global does not exist +-- yet at that point — it is populated before ADDON_LOADED, which is AFTER every +-- addon file has run — so the read was always nil and the hook was NEVER +-- installed, no matter what the saved file said. Ticking the box, reloading, +-- and finding it still off was that, exactly: the value saved correctly and was +-- then read a frame too early, forever. +-- Every other consumer of this table in the addon reads it inside a function +-- (DF:GetGlobalDB and friends) and so never hit this. +-- +-- ⚠ The install CANNOT simply move to ADDON_LOADED either: this file sits at +-- TOC line 68 deliberately (see the "Profiler.lua is loaded earlier" note in +-- the TOC) so the hook is in place before the files that install an OnUpdate at +-- THEIR file scope — five ticker/throttle singletons across Headers, StatusIcons +-- and AutoProfiles, all of which load later. (A sixth lived in Performance.lua, +-- since deleted.) Installing at +-- ADDON_LOADED would miss every one of them. +-- +-- So: install unconditionally and record from the first moment, then resolve the +-- user's setting at ADDON_LOADED and drop everything if they had it off. The +-- cost of being wrong for that one window is a table of a few dozen entries; +-- the cost of the hook itself when latched off is the two comparisons below, +-- since scriptType ~= "OnUpdate" rejects almost every SetScript call in the game +-- before anything else happens. +local onUpdateHookEnabled = nil -- nil = undecided (still loading); resolved below +Profiler.onUpdateHookEnabled = onUpdateHookEnabled + +local frameMeta = getmetatable(CreateFrame("Frame")).__index +hooksecurefunc(frameMeta, "SetScript", function(frame, scriptType, handler) + if onUpdateHookEnabled == false then return end -- latched off at ADDON_LOADED + if installingOnUpdate then return end + if scriptType ~= "OnUpdate" then return end + if not IsDFFrame(frame) then return end -- skip non-DF frames (taint safety) + + if handler then + onUpdateRegistry[frame] = handler + ResolveFrameLabel(frame) + -- If profiler is currently recording, wrap the new handler + -- right now so this newly added OnUpdate is visible from its + -- first frame. ⚠ Except our own window: opening the profiler UI + -- mid-recording installs its refresh handler through this hook, and + -- Profiler:Start's exclusion list only covers frames that already + -- existed. Both doors need the same guard. + if Profiler.active and WrapFrameOnUpdate + and frame ~= tickFrame and frame ~= Profiler.frame then + WrapFrameOnUpdate(frame, handler) + end + else + -- nil handler = OnUpdate removed; drop bookkeeping. + onUpdateRegistry[frame] = nil + if onUpdateWrapped[frame] then + onUpdateWrapped[frame] = nil + end + end +end) + +-- Resolve the setting at the first moment SavedVariables actually exist. +local hookSettingFrame = CreateFrame("Frame") +hookSettingFrame:RegisterEvent("ADDON_LOADED") +hookSettingFrame:SetScript("OnEvent", function(self, _, loadedAddon) + if loadedAddon ~= addonName then return end + self:UnregisterEvent("ADDON_LOADED") + onUpdateHookEnabled = (DandersFramesDB_v2 + and DandersFramesDB_v2.profilerOnUpdateHook == true) or false + Profiler.onUpdateHookEnabled = onUpdateHookEnabled + if not onUpdateHookEnabled then + -- Opted out: throw away what the load window collected and latch the + -- hook off. Nothing else in the profiler reads these while disabled. + wipe(onUpdateRegistry) + wipe(onUpdateLabels) + wipe(onUpdateDFCheck) + end +end) + +combatFrame:SetScript("OnEvent", function(self, event) + if event == "PLAYER_REGEN_DISABLED" then + -- Entering combat + if not Profiler.active then + Profiler:Start() + if Profiler.frame and Profiler.frame:IsShown() and Profiler.UpdateUI then + Profiler.UpdateUI() + end + end + elseif event == "PLAYER_REGEN_ENABLED" then + -- Leaving combat + if Profiler.active then + Profiler:Stop() + Profiler:PrintResults() + if Profiler.frame and Profiler.frame:IsShown() and Profiler.UpdateUI then + Profiler.UpdateUI() + end + end + end +end) + +function Profiler:SetCombatAuto(enabled) + self.combatAuto = enabled + if enabled then + combatFrame:RegisterEvent("PLAYER_REGEN_DISABLED") + combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED") + DF:Say("Combat auto-profile |cff00ff00ON|r — will start on combat, stop + print on combat end.") + else + combatFrame:UnregisterEvent("PLAYER_REGEN_DISABLED") + combatFrame:UnregisterEvent("PLAYER_REGEN_ENABLED") + DF:Say("Combat auto-profile |cffff4444OFF|r") + end +end + +-- ============================================================ +-- PROFILED FUNCTIONS +-- Each entry is a path string. Plain "Name" resolves to DF[Name]. +-- Dotted "Mod.Sub.Name" resolves to DF.Mod.Sub.Name. Missing entries +-- are silently skipped, so it's safe to list optional features here. +-- ============================================================ + +local PROFILED_FUNCTIONS = { + -- ---------------------------------------------------------- + -- Core per-unit updates (event hot path) + -- ---------------------------------------------------------- + "UpdateUnitFrame", + "UpdateHealthFast", -- Lean UNIT_HEALTH hot path + "UpdateHealth", + "UpdatePower", + "UpdateName", + "UpdateFrame", + "UpdateResourceBar", + + -- ---------------------------------------------------------- + -- Aura pipeline (12.1 factory: per-event cost is the sig-compare + -- walk in the Drive* functions; Blizzard renders the icons) + -- ---------------------------------------------------------- + "UpdateAuras_Enhanced", + "RebuildDirectFilterStrings", + "DriveBuffFactory", + "DriveDebuffFactory", + "DriveDefensiveFactory", + "DriveMissingBuffFactory", + "RefreshFactoryRows", + "BuildAuraRowConfig", + "BuildDebuffFilterRecords", + "FilterRegistry.ResolveSelection", + "FilterRegistry.SelectionSignature", + + -- ---------------------------------------------------------- + -- Dispel + -- ---------------------------------------------------------- + "UpdateDispelOverlay", + "UpdateDispelGradientHealth", + "UpdateAllDispelOverlays", + + -- ---------------------------------------------------------- + -- Absorb / Heal Prediction + -- ---------------------------------------------------------- + "UpdateAbsorb", + "UpdateHealAbsorb", + "UpdateHealPrediction", + + -- ---------------------------------------------------------- + -- Range + -- ---------------------------------------------------------- + "UpdateRange", + "UpdatePetRange", + "RefreshRangeSpell", + + -- ---------------------------------------------------------- + -- Visual / Highlights / Health Fade + -- ---------------------------------------------------------- + "UpdateHighlights", + "UpdateAnimatedBorder", + "ApplyDeadFade", + "ApplyHealthColors", + "ApplyBarOrientation", + "ApplyHealthFadeAlpha", + "UpdateHealthFade", + "UpdatePetHealthFade", + + -- ---------------------------------------------------------- + -- Status icons (per-unit) + -- ---------------------------------------------------------- + "UpdateRoleIcon", + "UpdateLeaderIcon", + "UpdateRaidTargetIcon", + "UpdateReadyCheckIcon", + "UpdateCenterStatusIcon", + "UpdateSummonIcon", + "UpdateResurrectionIcon", + "UpdatePhasedIcon", + "UpdateAFKIcon", + "UpdateVehicleIcon", + "UpdateRaidRoleIcon", + "UpdateAllStatusIcons", -- per-frame sweep of all icons + "UpdateRestedIndicator", + + -- ---------------------------------------------------------- + -- Defensive / external def icons + missing-buff + -- ---------------------------------------------------------- + "UpdateMissingBuffIcon", + "UpdateExternalDefIcon", + "UpdateDefensiveBar", + + -- ---------------------------------------------------------- + -- Aura Designer (per-frame) + -- ---------------------------------------------------------- + "GetADTrackedSpellIDs", + "GetClaimedDebuffCategories", + "BuildADIdentityFilters", + "AuraDesigner.Factory.SyncFrame", + "AuraDesigner.Factory.ClearFrame", + + -- ---------------------------------------------------------- + -- Text Designer + -- Driven from the central header dispatcher (tdRefresh, Frames/Headers.lua) + -- once per event per frame — plus the pinned mirror — and "health" is one of + -- the hints. On 12.1 the aura rows render game-side, so this is plausibly + -- the largest per-frame consumer LEFT IN LUA. It was absent from this table + -- entirely, which made the system most worth measuring invisible. + -- + -- Three levels on purpose: the DF: entry gives the total, UpdateFrame is the + -- per-frame worker, and Resolve is per ELEMENT — the multiplier that turns a + -- cheap frame into an expensive one. + -- + -- ⚠ Resolve is the highest-CALL-COUNT target in this table (elements × + -- frames × ticks), which is exactly the shape the overhead correction + -- exists for: a per-call tax lands hardest on the row with the most calls. + -- Its figure is now overhead-corrected rather than an upper bound. (The + -- two collectgarbage("count") calls the wrapper makes never inflated it — + -- they sit outside the timed window. They cost the GAME time while + -- profiling, but they were never charged to the function.) + -- + -- ⚠ Resolve lands in the UNCLASSIFIED ("?") bucket, and that is correct, not + -- a bug: the bucket is picked from the first argument's .unit, and Resolve + -- takes (elem, source) — an element config, not a frame. The other two are + -- frame-first and split party/raid/pinned normally. + -- ---------------------------------------------------------- + "UpdateTextDesigner", + "TextDesigner.Render.UpdateFrame", + "TextDesigner.Resolver.Resolve", + + -- (Removed) the Targeted Spells entry "UpdateTargetedSpellLayout" — that function + -- went with the group-frame display. Not a silent stale entry: Profiler:Start + -- reports targets it cannot resolve, so this printed an unresolved-target line on + -- every profiler start. + + -- ---------------------------------------------------------- + -- Pets + -- ---------------------------------------------------------- + "UpdatePetFrame", + "UpdatePetHealth", + "UpdatePetName", + "ApplyPetFrameStyle", + + -- ---------------------------------------------------------- + -- Layout / Style (called per-frame on layout changes) + -- ---------------------------------------------------------- + "ApplyFrameLayout", + "ApplyFrameStyle", + "FullFrameRefresh", + + -- ---------------------------------------------------------- + -- Bulk sweeps (called on roster / settings events) + -- ---------------------------------------------------------- + "UpdateAllFrames", + "UpdateAllPetFrames", + "UpdateAllRaidPetFrames", + "UpdateAllPetFramePositions", + "UpdateAllAuras", + "UpdateAllMissingBuffIcons", + "UpdateAllFramesStatusIcons", + "UpdateAllRoleIcons", + "UpdateAllDefensiveBars", + "UpdateAllExternalDefIcons", + "UpdateAllElementAppearances", + "UpdateAllFrameAppearances", + "RefreshLiveFrames", + "RefreshAllVisibleFrames", + "RefreshRaidGroupFrames", + "RefreshPartyFrames", + "RefreshAllHeaderChildFrames", + "RefreshRaidFlatFrames", + "UpdateLiveRaidFrames", + + -- ---------------------------------------------------------- + -- Roster handling (called on GROUP_ROSTER_UPDATE) + -- ---------------------------------------------------------- + "ProcessRosterUpdate", + "ProcessRoleUpdate", + "RebuildUnitFrameMap", + "UpdateHeaderFrameCount", + "UpdateRaidGroupOrderAttributes", + "ApplyRaidGroupSorting", + "ApplyPartyGroupSorting", + + -- ---------------------------------------------------------- + -- Power event registration (changes when role/spec/group changes) + -- ---------------------------------------------------------- + "UpdatePowerEventRegistration", + "UpdateAllPowerEventRegistration", + + -- ---------------------------------------------------------- + -- Blizzard integration + -- ---------------------------------------------------------- + "UpdateBlizzardFrameVisibility", +} + +-- ============================================================ +-- PROFILED EVENT DISPATCHERS +-- Each entry points at a function on DF that the addon's event frames +-- call through. Wrapping these gives us per-event timing for everything +-- those frames receive — without touching individual modules. +-- ============================================================ + +local PROFILED_EVENT_DISPATCHERS = { + -- Roster UNIT_* events: every UNIT_AURA / UNIT_HEALTH / UNIT_POWER + -- for every roster member flows through this single trampoline. + { container = DF, key = "_RouteRosterEvent", source = "Roster" }, + -- Main eventFrame: ADDON_LOADED, GROUP_ROSTER_UPDATE, + -- PLAYER_REGEN_*, PLAYER_SPECIALIZATION_CHANGED, etc. + { container = DF, key = "_MainEventDispatcher", source = "Main" }, +} + +-- ============================================================ +-- FORMAT HELPERS +-- ============================================================ + +local function CommaNumber(n) + local s = tostring(floor(n)) + return s:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "") +end + +local function FormatMs(ms) + if ms >= 1000 then + return format("%.1fs", ms / 1000) + elseif ms >= 0.1 then + return format("%.1f", ms) + else + return format("%.2f", ms) + end +end + +local function FormatUs(ms) + local us = ms * 1000 + if us >= 100 then return format("%.0f", us) + elseif us >= 10 then return format("%.1f", us) + else return format("%.2f", us) end +end + +-- Bytes per call: small numbers as raw bytes, larger as KB. +-- "0" -> "·" so the row reads cleanly when there's no allocation. +local function FormatBytes(b) + if b <= 0 then return "·" end + if b >= 1024 * 10 then + return format("%.0fk", b / 1024) + elseif b >= 1024 then + return format("%.1fk", b / 1024) + elseif b >= 100 then + return format("%.0f", b) + else + return format("%.0f", b) + end +end + +local function FormatPeak(n) + if n <= 0 then return "·" end + if n >= 1000 then return format("%.1fk", n / 1000) end + return tostring(n) +end + +local function FormatElapsed(seconds) + if seconds >= 60 then + return format("%dm %ds", floor(seconds / 60), floor(seconds % 60)) + else + return format("%.1fs", seconds) + end +end + +-- ============================================================ +-- OVERHEAD CALIBRATION +-- ============================================================ +-- The wrapper cannot measure a function without also measuring a little of +-- itself. What lands INSIDE the timed window is the tail of the opening +-- debugprofilestop, the vararg pack and 5-return dispatch into the original, +-- and the head of the closing debugprofilestop. Small — but it is charged +-- once per call, so it scales with call COUNT, not with cost. Left +-- uncorrected it quietly taxes the hot-and-cheap functions (Resolver.Resolve +-- runs elements x frames x ticks) and barely touches the cold-and-expensive +-- ones, which is precisely backwards from what you want when ranking. +-- +-- So measure it: run the identical pattern around a function that does +-- nothing, and whatever it reports IS the overhead. +-- +-- ⚠ THE ESTIMATOR IS THE MINIMUM OF BATCH MEANS, and the batches are kept +-- SHORT on purpose. Averaging within a batch is what defeats timer +-- quantisation — individual samples of a do-nothing call frequently read 0, +-- so a minimum over raw samples would report ~0 and under-subtract, which is +-- worse than not correcting at all. Taking the minimum ACROSS batches is what +-- discards contamination: a batch that caught a GC step reads high, and only +-- the cleanest batch is kept. Long batches break that, because with enough +-- iterations every batch catches a GC step and the "clean" floor is a floor +-- of dirty numbers. Hence many short batches rather than a few long ones. +-- +-- ⚠ KEEP THIS LOOP SHAPED LIKE THE REAL WRAPPER. It mimics the call form +-- deliberately: two args plus varargs in, five returns captured out. Change +-- the wrappers' call form and this stops measuring the same thing, and the +-- correction silently becomes wrong rather than absent. +-- +-- What this does NOT remove: the rest of the wrapper (the two +-- collectgarbage("count") calls, the bucket writes, the stack push/pop) sits +-- outside the window and so never enters this row's own numbers — but for a +-- NESTED call it does land inside its parent's window, where nothing can +-- separate it from real work. Deeply-nested rows therefore keep a small +-- residual. It is bounded by (wrapped calls beneath) x (a fraction of a +-- microsecond) and is the reason the legend says "corrected", not "exact". +-- 25 x 80 = the same 2000 samples as the original 5 x 500, redistributed so a +-- GC-free batch is likely to exist. See the estimator note above before changing +-- these: raising ITERS while lowering BATCHES quietly re-breaks the correction. +local CAL_BATCHES = 25 +local CAL_ITERS = 80 + +local function CalibrateOverhead() + local noop = function() end + local dummy = {} + + -- One wrapper-shaped measurement. Returns what the wrapper would have + -- recorded for a call that costs nothing. + local function measureOnce(selfArg, a1, ...) + local t0 = debugprofilestop() + local r1, r2, r3, r4, r5 = noop(selfArg, a1, ...) + return debugprofilestop() - t0 + end + + -- Warm-up: first calls through a fresh closure are not representative. + for _ = 1, CAL_ITERS do measureOnce(dummy, 1) end + + local best + for _ = 1, CAL_BATCHES do + local sum = 0 + for _ = 1, CAL_ITERS do + sum = sum + measureOnce(dummy, 1) + end + local mean = sum / CAL_ITERS + if not best or mean < best then best = mean end + end + + return best or 0 +end + +-- Strip the instrumentation's own cost out of one row. +-- ownCalls invocations of this row itself +-- deepCalls wrapped invocations nested beneath it — their in-window +-- overhead is sitting inside this row's INCLUSIVE total, so it +-- comes off the total but not off self time. +-- Clamped: a function genuinely cheaper than the wrapper reads as 0 rather +-- than negative, and self can never exceed the corrected inclusive total. +local function Corrected(totalMs, selfMs, ownCalls, deepCalls) + local oh = Profiler.overheadMs + if oh <= 0 then return totalMs, selfMs end + local t = totalMs - oh * (ownCalls + (deepCalls or 0)) + local s = selfMs - oh * ownCalls + if t < 0 then t = 0 end + if s < 0 then s = 0 end + if s > t then s = t end + return t, s +end + +-- (Removed) SumCorrected. It walked the raw buckets and corrected each one, while +-- GetSortedResults aggregates a function's per-frame-type buckets FIRST and corrects +-- the total once. Those are not the same number: Corrected clamps at zero, so +-- correcting five buckets independently and adding them loses the negative headroom +-- that a single combined correction keeps. The status line and the % denominator +-- could therefore disagree. GetGrandTotalMs now asks GetSortedResults instead, so +-- there is one aggregation and they cannot drift apart. + +-- ============================================================ +-- CORE PROFILING +-- ============================================================ + +-- Resolve a dotted path like "Mod.Sub.Name" against the DF table. +-- Returns: container_table, leaf_key, current_value (or nil if anything is missing). +local function ResolveFunctionPath(path) + local container = DF + local lastDot = 1 + while true do + local dot = path:find(".", lastDot, true) + if not dot then + local key = path:sub(lastDot) + return container, key, container[key] + end + local segment = path:sub(lastDot, dot - 1) + container = container[segment] + if type(container) ~= "table" then return nil end + lastDot = dot + 1 + end +end + +-- Wrap a single frame's OnUpdate handler. Idempotent: a second call on +-- the same frame is a no-op so prospective wrapping from the SetScript +-- hook can't double-instrument. Stats are keyed by the frame's label, +-- which means multiple frames sharing a name (rare, e.g. pooled frames) +-- aggregate into one row — usually what you want. +WrapFrameOnUpdate = function(frame, original) + if onUpdateWrapped[frame] then return end + + local label = ResolveFrameLabel(frame) + local stats = Profiler.updateData[label] + if not stats then + stats = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + Profiler.updateData[label] = stats + end + + local wrapped = function(self, elapsed, ...) + -- OnUpdate handlers are stack ROOTS in practice (the C layer calls + -- them sequentially, never nested) but they call plenty of wrapped DF + -- methods, so they push a frame like everything else — that is what + -- keeps "time in the handler itself" separate from "time in the + -- functions it drove". + local d = callDepth + 1 + callDepth = d + childMs[d] = 0 + childN[d] = 0 + + local m0 = collectgarbage("count") + local t0 = debugprofilestop() + original(self, elapsed, ...) + local elapsedMs = debugprofilestop() - t0 + local mDelta = collectgarbage("count") - m0 + if mDelta < 0 then mDelta = 0 end + + local ct, cn = childMs[d], childN[d] + callDepth = d - 1 + if d > 1 then + childMs[d - 1] = childMs[d - 1] + elapsedMs + childN[d - 1] = childN[d - 1] + cn + 1 + end + local selfDelta = elapsedMs - ct + if selfDelta < 0 then selfDelta = 0 end + + stats.calls = stats.calls + 1 + stats.total = stats.total + elapsedMs + stats.selfMs = stats.selfMs + selfDelta + stats.descN = stats.descN + cn + stats.mem = stats.mem + mDelta + if elapsedMs > stats.max then stats.max = elapsedMs end + end + + onUpdateWrapped[frame] = { original = original, wrapped = wrapped } + + installingOnUpdate = true + frame:SetScript("OnUpdate", wrapped) + installingOnUpdate = false +end + +function Profiler:Start() + if self.active then + DF:Say("Already recording.") + return + end + + -- Measure the wrapper's own in-window cost before anything is wrapped, so + -- the correction reflects THIS machine under THESE conditions rather than + -- a constant baked in at authoring time. Taken before startTime so the + -- calibration loop itself never lands inside the profiled window. + self.overheadMs = CalibrateOverhead() + + self.active = true + self.startTime = debugprofilestop() + self.stopTime = 0 + self:_SnapshotContainerStats() + self.containerDelta = nil + wipe(self.data) + wipe(self.tickStats) + wipe(self.originals) + wipe(self.eventData) + wipe(self.eventOriginals) + wipe(self.updateData) + tickRef[1] = 0 + callDepth = 0 + + local wrapped = 0 + -- ☠ An unresolved target used to vanish without a trace: the branch below + -- just skips it, and the "N functions instrumented" line gives no clue that + -- N is short. Rename or delete a profiled function and it silently stops + -- being measured — you read a clean profile and conclude the system is + -- cheap. Collect the misses and name them. + local missing = {} + local typeCheck = type -- cache as upvalue + + for _, path in ipairs(PROFILED_FUNCTIONS) do + local container, key, original = ResolveFunctionPath(path) + local resolved = container and type(original) == "function" + if not resolved then missing[#missing + 1] = path end + if resolved then + -- Save originals so Stop() can fully restore. + self.originals[path] = { container = container, key = key, original = original } + + -- Per-frame-type buckets. Each entry tracks: calls, total + -- (inclusive) ms, selfMs (exclusive of wrapped children), descN + -- (wrapped calls beneath, for the overhead correction), max + -- single-call ms, and total memory delta (KB). + local dP = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + local dR = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + local dHP = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + local dHR = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + local dU = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + + self.data[path .. "|P"] = dP + self.data[path .. "|R"] = dR + self.data[path .. "|HP"] = dHP + self.data[path .. "|HR"] = dHR + self.data[path .. "|?"] = dU + + -- Per-function tick stats: how many times has this function been + -- called within the current frame tick? Tracks the worst tick. + local ts = { lastTick = -1, calls = 0, maxPerTick = 0 } + self.tickStats[path] = ts + + local orig = original + local tref = tickRef -- upvalue cache + + container[key] = function(selfArg, a1, ...) + -- Tick spike accounting (cheap: 1 table read + 2 compares) + local cur = tref[1] + if ts.lastTick ~= cur then + ts.lastTick = cur + ts.calls = 0 + end + ts.calls = ts.calls + 1 + if ts.calls > ts.maxPerTick then + ts.maxPerTick = ts.calls + end + + -- Push a call-stack frame. Deliberately OUTSIDE the timed + -- window below: this bookkeeping is instrumentation, not the + -- function's cost, and charging it here would defeat the + -- correction it exists to enable. + local d = callDepth + 1 + callDepth = d + childMs[d] = 0 + childN[d] = 0 + + -- Time + memory delta around the call. + -- collectgarbage("count") returns kilobytes; deltas are exact + -- between calls, but a GC cycle running mid-call shows as + -- negative — we clamp to 0 to avoid skew. + local m0 = collectgarbage("count") + local t0 = debugprofilestop() + local r1, r2, r3, r4, r5 = orig(selfArg, a1, ...) + local elapsed = debugprofilestop() - t0 + local mDelta = collectgarbage("count") - m0 + if mDelta < 0 then mDelta = 0 end + + -- Pop: hand our inclusive time and call count up to whoever + -- called us, so THEIR self time excludes us. + local ct, cn = childMs[d], childN[d] + callDepth = d - 1 + if d > 1 then + childMs[d - 1] = childMs[d - 1] + elapsed + childN[d - 1] = childN[d - 1] + cn + 1 + end + local selfDelta = elapsed - ct + if selfDelta < 0 then selfDelta = 0 end + + -- Classify: 2-3 field lookups on the first argument + local bucket + if typeCheck(a1) == "table" and a1.unit then + if a1.isPinnedFrame then + bucket = a1.isRaidFrame and dHR or dHP + else + bucket = a1.isRaidFrame and dR or dP + end + else + bucket = dU + end + bucket.calls = bucket.calls + 1 + bucket.total = bucket.total + elapsed + bucket.selfMs = bucket.selfMs + selfDelta + bucket.descN = bucket.descN + cn + bucket.mem = bucket.mem + mDelta + if elapsed > bucket.max then bucket.max = elapsed end + + return r1, r2, r3, r4, r5 + end + + wrapped = wrapped + 1 + end + end + + -- ---------------------------------------------------------- + -- Wrap event dispatchers. Each wrapped dispatcher records into + -- self.eventData[eventName], indexed by the event name (the second + -- argument the dispatcher receives). One dispatcher feeds many + -- event names, so the bucket is created lazily on first occurrence. + -- ---------------------------------------------------------- + local eventData = self.eventData + local eventsWrapped = 0 + for _, dispatcher in ipairs(PROFILED_EVENT_DISPATCHERS) do + local container = dispatcher.container + local key = dispatcher.key + local original = container[key] + if type(original) == "function" then + self.eventOriginals[key] = { container = container, key = key, original = original } + local orig = original + local source = dispatcher.source + + container[key] = function(selfArg, event, ...) + -- Dispatchers sit at the root of nearly every call chain DF + -- runs, so their self time — the dispatch and routing work, + -- with the profiled methods they drive taken back out — is + -- the number that says whether routing itself is expensive. + local d = callDepth + 1 + callDepth = d + childMs[d] = 0 + childN[d] = 0 + + local m0 = collectgarbage("count") + local t0 = debugprofilestop() + local r1, r2, r3, r4, r5 = orig(selfArg, event, ...) + local elapsed = debugprofilestop() - t0 + local mDelta = collectgarbage("count") - m0 + if mDelta < 0 then mDelta = 0 end + + local ct, cn = childMs[d], childN[d] + callDepth = d - 1 + if d > 1 then + childMs[d - 1] = childMs[d - 1] + elapsed + childN[d - 1] = childN[d - 1] + cn + 1 + end + local selfDelta = elapsed - ct + if selfDelta < 0 then selfDelta = 0 end + + local bucket = eventData[event] + if not bucket then + bucket = { calls = 0, total = 0, selfMs = 0, descN = 0, + max = 0, mem = 0, source = source } + eventData[event] = bucket + end + bucket.calls = bucket.calls + 1 + bucket.total = bucket.total + elapsed + bucket.selfMs = bucket.selfMs + selfDelta + bucket.descN = bucket.descN + cn + bucket.mem = bucket.mem + mDelta + if elapsed > bucket.max then bucket.max = elapsed end + + return r1, r2, r3, r4, r5 + end + eventsWrapped = eventsWrapped + 1 + end + end + + -- ---------------------------------------------------------- + -- Wrap all currently-known DF OnUpdate handlers. The SetScript + -- hook has been recording these since addon load, so this catches + -- every handler installed before the profiler started. Handlers + -- added AFTER this point are wrapped on the spot by the same hook. + -- + -- Defense in depth: re-run IsDFFrame on each registry entry here. + -- The filter was added partway through the profiler's life, so the + -- registry may contain stale non-DF frames recorded before the + -- filter existed. Scrub them out instead of wrapping them, which + -- would cause taint errors in Blizzard's secure frame updates. + -- ---------------------------------------------------------- + local updatesWrapped = 0 + local toRemove + for frame, handler in pairs(onUpdateRegistry) do + if frame == tickFrame or frame == Profiler.frame then + -- Don't profile our own machinery. tickFrame drives the spike counter; + -- Profiler.frame is the window's own half-second refresh, and it is named + -- "DFProfilerFrame" so IsDFFrame matches it like any other DF frame. Left + -- in, the profiler's own UI showed up in its own OnUpdate table — and it + -- got busier the longer you watched, because the table it renders grows. + elseif not IsDFFrame(frame) then + -- Stale non-DF entry (recorded pre-filter). Drop it. + toRemove = toRemove or {} + toRemove[#toRemove + 1] = frame + else + WrapFrameOnUpdate(frame, handler) + updatesWrapped = updatesWrapped + 1 + end + end + if toRemove then + for i = 1, #toRemove do + onUpdateRegistry[toRemove[i]] = nil + end + end + + -- Start the per-tick OnUpdate that drives spike detection. + tickFrame:Show() + + DF:Say(format("Recording — %d/%d functions, %d events, %d OnUpdate handlers instrumented (wrapper overhead %sus/call, subtracted)", + wrapped, #PROFILED_FUNCTIONS, eventsWrapped, updatesWrapped, FormatUs(self.overheadMs))) + -- Naming them matters more than counting them: "91/94" tells you something + -- broke, the list tells you WHAT. A target goes missing when the function is + -- renamed or deleted and this table is not updated with it. + if #missing > 0 then + DF:Say("Profiler targets that did NOT resolve (not measured)", tostring(#missing), "BAD") + for _, path in ipairs(missing) do + print(" " .. DF.OUT.BAD .. path .. "|r") + end + end +end + +function Profiler:Stop() + if not self.active then return end + self.stopTime = debugprofilestop() + self.active = false + self.containerDelta = self:_ContainerStatsDelta() + + -- Restore originals on their actual container tables (supports dotted paths) + for _, entry in pairs(self.originals) do + entry.container[entry.key] = entry.original + end + for _, entry in pairs(self.eventOriginals) do + entry.container[entry.key] = entry.original + end + + -- Restore OnUpdate handlers. The installingOnUpdate guard suppresses + -- the SetScript hook so it doesn't immediately re-wrap them. + for frame, entry in pairs(onUpdateWrapped) do + installingOnUpdate = true + frame:SetScript("OnUpdate", entry.original) + installingOnUpdate = false + end + wipe(onUpdateWrapped) + + -- Stop the per-tick OnUpdate so profiler has zero idle cost when stopped. + -- That also stops the per-frame callDepth reset, so clear it here — the + -- next Start would otherwise inherit whatever an error left behind. + tickFrame:Hide() + callDepth = 0 + + DF:Say("Profiler stopped", FormatElapsed(self:GetElapsedSeconds()), "NEUTRAL") +end + +function Profiler:Reset() + for _, d in pairs(self.data) do + d.calls = 0 + d.total = 0 + d.selfMs = 0 + d.descN = 0 + d.max = 0 + d.mem = 0 + end + for _, ts in pairs(self.tickStats) do + ts.lastTick = -1 + ts.calls = 0 + ts.maxPerTick = 0 + end + -- Event + update buckets are created lazily so just wipe them. + wipe(self.eventData) + for _, d in pairs(self.updateData) do + d.calls = 0 + d.total = 0 + d.selfMs = 0 + d.descN = 0 + d.max = 0 + d.mem = 0 + end + callDepth = 0 + if self.active then + self.startTime = debugprofilestop() + self:_SnapshotContainerStats() + self.containerDelta = nil + end +end + +-- ============================================================ +-- CONTAINER LIFECYCLE COUNTERS +-- DF.AuraContainer.stats counts builds / teardowns / combat-deferred +-- rebuilds for the whole session. The profiler snapshots it on Start and +-- reports the delta: on 12.1 the per-update aura cost lives game-side, so +-- the number that matters is how often we RECREATE containers. Nonzero +-- builds during steady-state combat = a structural-signature bug +-- (rebuild storm), not normal operation. +-- ============================================================ + +function Profiler:_SnapshotContainerStats() + local s = DF.AuraContainer and DF.AuraContainer.stats + if s then + self.containerStats0 = { builds = s.builds, teardowns = s.teardowns, defers = s.defers } + else + self.containerStats0 = nil + end +end + +function Profiler:_ContainerStatsDelta() + local s = DF.AuraContainer and DF.AuraContainer.stats + local s0 = self.containerStats0 + if not (s and s0) then return nil end + return { + builds = s.builds - s0.builds, + teardowns = s.teardowns - s0.teardowns, + defers = s.defers - s0.defers, + } +end + +-- Delta for the current window: live while recording, frozen at Stop. +function Profiler:GetContainerDelta() + if self.active then return self:_ContainerStatsDelta() end + return self.containerDelta +end + +function Profiler:Toggle() + if self.active then self:Stop() else self:Start() end +end + +function Profiler:GetElapsedSeconds() + if self.startTime == 0 then return 0 end + local endTime = self.active and debugprofilestop() or self.stopTime + return (endTime - self.startTime) / 1000 +end + +local function GetActiveSource(self) + if self.viewMode == "events" then return self.eventData end + if self.viewMode == "updates" then return self.updateData end + return self.data +end + +function Profiler:GetTotalCalls() + local total = 0 + for _, d in pairs(GetActiveSource(self)) do total = total + d.calls end + return total +end + +-- ============================================================ +-- WHICH TOTAL IS A LEGITIMATE SUM +-- ---------------------------------------------------------- +-- ☠ Functions view: these rows NEST inside each other by design, so adding +-- their inclusive times counts the same microsecond once per level. It is not +-- a total of anything. Self time is the only honest sum here, and it is what +-- the % column is a share of. +-- +-- Events / OnUpdate views: those rows do not nest — one event dispatch never +-- runs inside another, and the C layer calls OnUpdate handlers one after the +-- other rather than within each other. Inclusive is both a valid sum and the +-- more useful reading there ("what did this event cost me, all in"), so those +-- two views keep it. +local function ViewUsesSelf(mode) + return mode == "functions" +end + +-- ⚠ Deliberately delegates rather than summing the buckets itself. A second +-- aggregation is a second chance to disagree with the table the user is reading, +-- and the two DID disagree. The extra sort costs nothing worth counting: this is a +-- debug window refreshing twice a second, and UpdateUI calls GetSortedResults anyway. +function Profiler:GetGrandTotalMs() + local _, grand = self:GetSortedResults() + return grand or 0 +end + +-- Display name suffixes for frame types +local TYPE_LABELS = { + ["|P"] = " [Party]", + ["|R"] = " [Raid]", + ["|HP"] = " [HL-P]", + ["|HR"] = " [HL-R]", + ["|?"] = "", -- no suffix for "other" (functions that don't take a frame) +} + +function Profiler:GetSortedResults() + if self.viewMode == "events" then + return self:GetSortedEventResults() + end + if self.viewMode == "updates" then + return self:GetSortedUpdateResults() + end + + local results = {} + local grandSelf = 0 + local tickStats = self.tickStats + local oh = self.overheadMs + + -- Max is a single-call inclusive figure, so exactly one wrapper's worth of + -- overhead is in it. Children nested under that one call carry theirs too + -- and cannot be separated out — this corrects what is knowable. + local function CorrectedMax(m) + local v = m - oh + return v > 0 and v or 0 + end + + if self.splitByFrame then + -- Split mode: one row per function+type combination (only those with calls) + for key, d in pairs(self.data) do + if d.calls > 0 then + local total, selfMs = Corrected(d.total, d.selfMs, d.calls, d.descN) + grandSelf = grandSelf + selfMs + -- Parse "funcName|type" into base name + suffix + local baseName, suffix = key:match("^(.+)(|.+)$") + if not baseName then + baseName = key + suffix = "" + end + local displayName = baseName .. (TYPE_LABELS[suffix] or suffix) + local ts = tickStats[baseName] + results[#results + 1] = { + name = displayName, + calls = d.calls, + total = total, + selfMs = selfMs, + avg = total / d.calls, + max = CorrectedMax(d.max), + -- Memory: bytes per call (KB delta * 1024 / calls) + mem = d.calls > 0 and (d.mem * 1024 / d.calls) or 0, + -- Peak/tick is per-function, not per-bucket; show the same + -- value on every split row for that function. + peak = ts and ts.maxPerTick or 0, + } + end + end + else + -- Aggregate mode: combine all frame types into one row per function. + -- ⚠ Sum the RAW fields and correct once at the end. Correcting each + -- bucket first and then adding would clamp each partial at zero + -- independently, so a function that is cheap in party and expensive in + -- raid would lose the party rows' negative headroom and read high. + local aggregated = {} + local aggOrder = {} -- preserve insertion order for deterministic iteration + for key, d in pairs(self.data) do + if d.calls > 0 then + local baseName = key:match("^(.+)|") or key + if not aggregated[baseName] then + aggregated[baseName] = { calls = 0, total = 0, selfMs = 0, descN = 0, max = 0, mem = 0 } + aggOrder[#aggOrder + 1] = baseName + end + local agg = aggregated[baseName] + agg.calls = agg.calls + d.calls + agg.total = agg.total + d.total + agg.selfMs = agg.selfMs + d.selfMs + agg.descN = agg.descN + d.descN + agg.mem = agg.mem + d.mem + if d.max > agg.max then agg.max = d.max end + end + end + + for _, baseName in ipairs(aggOrder) do + local agg = aggregated[baseName] + local total, selfMs = Corrected(agg.total, agg.selfMs, agg.calls, agg.descN) + grandSelf = grandSelf + selfMs + local ts = tickStats[baseName] + results[#results + 1] = { + name = baseName, + calls = agg.calls, + total = total, + selfMs = selfMs, + avg = total / agg.calls, + max = CorrectedMax(agg.max), + mem = agg.calls > 0 and (agg.mem * 1024 / agg.calls) or 0, + peak = ts and ts.maxPerTick or 0, + } + end + end + + -- Share of SELF time, not of the inclusive sum — see the ViewUsesSelf note. + -- These add up to 100%; the old inclusive shares did not add up to anything. + for _, r in ipairs(results) do + r.pct = grandSelf > 0 and (r.selfMs / grandSelf * 100) or 0 + end + local grandTotal = grandSelf + + local col = self.sortColumn + local desc = self.sortDesc + sort(results, function(a, b) + if col == "name" then + if desc then return a.name > b.name else return a.name < b.name end + end + if desc then return a[col] > b[col] else return a[col] < b[col] end + end) + + return results, grandTotal +end + +-- Build sorted rows from OnUpdate handler data. Same shape as the +-- function/event variants. Frame label is shortened to avoid blowing +-- out the name column when GetDebugName returns a long parent chain. +function Profiler:GetSortedUpdateResults() + local results = {} + local grandTotal = 0 + + local oh = self.overheadMs + + for label, d in pairs(self.updateData) do + if d.calls > 0 then + local total, selfMs = Corrected(d.total, d.selfMs, d.calls, d.descN) + grandTotal = grandTotal + total + -- Trim very long parent-chain labels: keep the last segment. + local short = label + if #label > 36 then + local tail = label:match("([^%.]+)$") + short = tail and ("…" .. tail) or label:sub(-36) + end + local mx = d.max - oh + results[#results + 1] = { + name = short, + calls = d.calls, + total = total, + selfMs = selfMs, + avg = total / d.calls, + max = mx > 0 and mx or 0, + mem = d.calls > 0 and (d.mem * 1024 / d.calls) or 0, + peak = 0, + } + end + end + + for _, r in ipairs(results) do + r.pct = grandTotal > 0 and (r.total / grandTotal * 100) or 0 + end + + local col = self.sortColumn + local desc = self.sortDesc + sort(results, function(a, b) + if col == "name" then + if desc then return a.name > b.name else return a.name < b.name end + end + if desc then return a[col] > b[col] else return a[col] < b[col] end + end) + + return results, grandTotal +end + +-- Build sorted rows from event dispatcher data. The shape matches the +-- function results so the existing UI code can render either without a +-- branch, except that the "peak" column is unused (always 0) for events +-- and the "name" carries the event name plus a small source tag. +function Profiler:GetSortedEventResults() + local results = {} + local grandTotal = 0 + + local oh = self.overheadMs + + for eventName, d in pairs(self.eventData) do + if d.calls > 0 then + local total, selfMs = Corrected(d.total, d.selfMs, d.calls, d.descN) + grandTotal = grandTotal + total + local mx = d.max - oh + results[#results + 1] = { + name = eventName .. (d.source and (" [" .. d.source .. "]") or ""), + calls = d.calls, + total = total, + -- Self here = dispatch and routing only, with the profiled DF + -- methods this event drove subtracted back out. A big Total + -- next to a tiny Self means the event is expensive because of + -- what it triggers, not because routing it costs anything. + selfMs = selfMs, + avg = total / d.calls, + max = mx > 0 and mx or 0, + mem = d.calls > 0 and (d.mem * 1024 / d.calls) or 0, + peak = 0, + } + end + end + + for _, r in ipairs(results) do + r.pct = grandTotal > 0 and (r.total / grandTotal * 100) or 0 + end + + local col = self.sortColumn + local desc = self.sortDesc + sort(results, function(a, b) + if col == "name" then + if desc then return a.name > b.name else return a.name < b.name end + end + if desc then return a[col] > b[col] else return a[col] < b[col] end + end) + + return results, grandTotal +end + +-- ============================================================ +-- QUICK PROFILE (timed auto-run, prints to chat) +-- ============================================================ + +function Profiler:QuickProfile(duration) + duration = duration or 10 + if self.active then self:Stop() end + + self:Start() + DF:Say("Profiler auto-stopping", duration .. "s", "NEUTRAL") + + C_Timer.After(duration, function() + if self.active then + self:Stop() + self:PrintResults() + if Profiler.frame and Profiler.frame:IsShown() and Profiler.UpdateUI then + Profiler.UpdateUI() + end + end + end) +end + +-- ============================================================ +-- PRINT TO CHAT +-- ============================================================ + +function Profiler:PrintResults() + local results, grandTotal = self:GetSortedResults() + local elapsed = self:GetElapsedSeconds() + local totalCalls = self:GetTotalCalls() + + if #results == 0 then + DF:Say("No data collected.") + return + end + + print(" ") + -- Name the denominator. In the functions view it is the sum of SELF times + -- (the only sum that doesn't count nested calls twice); in the other two, + -- where rows don't nest, it is the inclusive sum. + DF:Say(format("[%s] %s | %s calls | %sms %s CPU", + self.viewMode, FormatElapsed(elapsed), CommaNumber(totalCalls), FormatMs(grandTotal), + ViewUsesSelf(self.viewMode) and "self" or "inclusive")) + local cd = self:GetContainerDelta() + if cd then + -- Builds during steady-state combat = rebuild storm; see counter block above. + local warn = (cd.builds > 0) and "|cffffff88" or "|cff88ff88" + print(format(" %sAura containers: %d built, %d torn down, %d rebuilds deferred to combat end|r", + warn, cd.builds, cd.teardowns, cd.defers)) + end + print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") + + for i, r in ipairs(results) do + local color + if r.pct >= 25 then color = "|cffff6666" + elseif r.pct >= 10 then color = "|cffffff88" + else color = "|cff88ff88" end + + print(format(" %s%2d. %-36s|r %s calls %sms %sms self %sus avg %sus max pk %s %sB %s%5.1f%%|r", + color, i, r.name, + CommaNumber(r.calls), + FormatMs(r.total), + FormatMs(r.selfMs or 0), + FormatUs(r.avg), + FormatUs(r.max), + FormatPeak(r.peak), + FormatBytes(r.mem), + color, r.pct + )) + end + + print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") + print(" ") +end + +-- ============================================================ +-- SUMMARY (top-N across all three categories) +-- ============================================================ + +-- Helper: temporarily switch viewMode, run GetSortedResults, restore. +-- Used by PrintSummary so it can collect results from each category +-- without permanently flipping the user's UI view. +local function TopN(self, mode, n) + local prev = self.viewMode + self.viewMode = mode + local results, grand = self:GetSortedResults() + self.viewMode = prev + local out = {} + for i = 1, math.min(n, #results) do + out[i] = results[i] + end + return out, grand +end + +function Profiler:PrintSummary() + local elapsed = self:GetElapsedSeconds() + if elapsed <= 0 then + DF:Say("No data collected.") + return + end + + -- Aggregate totals across all three sources independently. Note + -- these will overlap (events drive functions which drive OnUpdate + -- ticks) — they're shown as separate lenses, not sums. + local _, funcGrand = TopN(self, "functions", 0) + local _, evtGrand = TopN(self, "events", 0) + local _, updGrand = TopN(self, "updates", 0) + + print(" ") + print(format("|cff00ff00DF Profiler Summary:|r %s elapsed", FormatElapsed(elapsed))) + print(format(" Functions: %sms SELF CPU across wrapped DF methods (nested calls counted once)", FormatMs(funcGrand))) + print(format(" Events: %sms inclusive CPU across event handlers", FormatMs(evtGrand))) + print(format(" OnUpdate: %sms inclusive CPU across every-frame handlers", FormatMs(updGrand))) + print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") + + local function dump(label, mode) + local rows = TopN(self, mode, 5) + if #rows == 0 then + print(format(" |cffaaaaaaTop 5 %s:|r (none)", label)) + return + end + print(format(" |cffffd700Top 5 %s:|r", label)) + for i, r in ipairs(rows) do + print(format(" %d. %-34s %s calls %sms %sms self %sus avg %s%%", + i, r.name, + CommaNumber(r.calls), + FormatMs(r.total), + FormatMs(r.selfMs or 0), + FormatUs(r.avg), + format("%.1f", r.pct))) + end + end + dump("Functions (by total ms)", "functions") + dump("Events (by total ms)", "events") + dump("OnUpdate (by total ms)", "updates") + + print(" " .. DF.OUT.NEUTRAL .. ("—"):rep(14) .. "|r") + print(" ") +end + +-- ============================================================ +-- SHARED WITH THE WINDOW (Debug/ProfilerUI.lua, load-on-demand) +-- ============================================================ +-- The window renders numbers with the same formatters the chat reports use, +-- so one change shows everywhere. It is a separate addon and cannot see this +-- file's locals; none is reassigned after this point. +Profiler.CommaNumber = CommaNumber +Profiler.FormatMs = FormatMs +Profiler.FormatUs = FormatUs +Profiler.FormatBytes = FormatBytes +Profiler.FormatPeak = FormatPeak +Profiler.FormatElapsed = FormatElapsed + +-- ☠ REPLACED by the real ToggleUI when the companion loads. Same pattern and +-- same loop guard as DF.ToggleGUI in GUI/LoadOptions.lua. +local toggleStub +toggleStub = function(self) + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then return end + if Profiler.ToggleUI == toggleStub then + DF:Err("profiler window unavailable -- companion loaded without ProfilerUI") + return + end + return Profiler:ToggleUI() +end +Profiler.ToggleUI = toggleStub + \ No newline at end of file diff --git a/DandersFrames_Options/Changelog.lua b/DandersFrames_Options/Changelog.lua new file mode 100644 index 00000000..917ac6bc --- /dev/null +++ b/DandersFrames_Options/Changelog.lua @@ -0,0 +1,222 @@ +-- GENERATED by generate_changelog.sh from CHANGELOG.md -- do not edit. +-- ☠ Companion addon: bind the parent's table from the global, never `...`. +local DF = DandersFrames +DF.CHANGELOG_TEXT = [===[ +# DandersFrames Changelog + +## [5.0.0] + +### WoW 12.1 (Midnight) Rework + +*The 12.1 rework is a big team effort — brought to you by Danders, Krathe and Maelareth. A single change below often includes work from more than one of us, so entries aren't credited individually.* + +DandersFrames has been rebuilt for WoW 12.1 (Midnight), which fundamentally changed how the game handles auras. This build runs on 12.1 only — 12.0.x stays on 4.7. Every aura row — buffs, debuffs, defensives and missing buffs — now renders through the game's new protected aura engine. + +* (Auras) **New: Dispel Text.** The debuff bar can show a short letter code on each debuff for its dispel type — Ma for Magic, Po for Poison, and so on — a colourblind-friendly cue alongside the coloured border. It uses the game's own wording for your language. Enable it in the new "Dispel Text" section on the Debuffs page, with font, size, colour and position options. No Accessibility setting is needed: the text shows whether or not Colorblind Mode is on. +* (Auras) New "Aura Duration Update Rate" setting (General > Settings) — choose how often aura countdown text refreshes: Smooth, Normal or Performance. Applies account-wide to the buff, debuff and defensive bars and Aura Designer indicators. +* (Auras) Permanent auras no longer show a duration timer on their icons. This is the new default for the buff, debuff and defensive bars and for Aura Designer indicators and groups — you can bring the timer back with the "Hide Duration on Permanent Auras" checkbox in each Duration section. +* (Auras) **New: Duration Bars.** Buff, debuff and defensive icons can now show a small bar that drains with each aura's remaining time, tucked just below (or above) the icon. Enable it in the new "Duration Bar" section on the Buffs, Debuffs and Defensive Icon pages, with height, gap, texture and colour options — and Aura Designer groups get the same section on each group card. +* (Auras) New sort controls: buff and debuff bars can show your own auras first and reverse the sort direction, and the defensive icon's order is now selectable (Externals First, Most Urgent, or Default). +* (Auras) **Duration text can now show a percentage.** The Duration Format dropdown gains a "Percent" option (shows "45%" instead of the seconds remaining), on the Buffs, Debuffs and Defensive Icon pages and on every Aura Designer indicator. The Aura Designer bar, which has room for a longer label, also gets a "Seconds + Percent" option that shows both at once (like "12s (45%)"). The old "Short" format is now labelled "Seconds", and "Full" (the wordy "14 Seconds" form) is no longer offered for icons where it never fit — it stays available for Aura Designer bars. Colour by Time still applies to the percentage, and "Hide Above" is unavailable while a percent format is selected (its threshold is in seconds). +* (Auras) **New "Order Applied" sort order.** Buffs and debuffs can now be sorted by the order they were applied, so an icon keeps its place for as long as the aura lasts instead of moving each time it refreshes — worth choosing if you track auras by position rather than by reading them. Available on the Buffs and Debuffs pages, and on every Aura Designer group card. "My Auras First" doesn't apply to it (it is one fixed order), so it greys out while it's selected. The Sort Order lists now read Default → Time Remaining → Alphabetical → Order Applied rather than alphabetically. +* (Tooltips) **Aura tooltip position and Disable in Combat work again.** The Tooltips page's Anchor, Offset and "Disable in Combat" controls for buff, debuff and defensive icons were greyed out on 12.1 as a game limitation — the new aura engine wouldn't let an addon place the tooltip. The current build restores that, so those settings drive the tooltip again: pin it to the icon with an offset, follow the cursor, or leave it on the game default, and hide it in combat. Changes apply live (at the end of combat if you're mid-fight). "Center" has no game equivalent and falls back to the default position. +* (Auras) The Dispellable debuff filter has a new "Any Dispel Type" mode — show every debuff with a dispel type, even ones nobody in the group can dispel. +* (Auras) New "Hide Permanent Auras" option for the buff bar — hides buffs with no duration, like auras that last until cancelled. +* (Aura Designer) Groups get sort controls too — each filter, other-buff and debuff group card now has its own Sort Order, "My Auras First" and "Reverse Order" options. +* (Auras) Changing aura filters, sort order, or max icons no longer rebuilds the icon rows — changes apply instantly in place, with no flicker. This covers the buff, debuff and defensive bars and the Aura Designer's filter and debuff groups. Changes made in combat apply the moment combat ends. +* (Aura Designer) **The Aura Designer now works for every spec** — previously healers and Augmentation only. The spec dropdown is grouped by class and searchable. +* (Aura Designer) **New: Filter Groups.** Place an entire filter on the frame as its own icon row — link built-in or custom filters, and every matching buff appears in a self-arranging row with its own position, icon size, growth and icon cap. Aura Filters changes apply to linked groups immediately, and buffs shown by a group leave the buff bar while "Hide Duplicate Buffs" is on. +* (Aura Designer) **New: Expiry Alerts.** Indicators can now warn you when a tracked buff is about to run out — below a threshold you choose, your own custom text or a warning glyph appears on the unit frame, positioned and sized however you like, so a tracked shield can literally say "Shield dropping!". Find it in each indicator's Expiry Warning section. +* (Aura Designer) Open dropdown menus now close when switching tabs instead of lingering over the new tab. +* (Auras) "Hide Long Debuffs" now also works while "All Debuffs" is enabled. "Keep important debuffs" still requires the category filters. +* (Aura Designer) Fix cooldown swipes on indicators and groups draining in the opposite direction from the buff and debuff bars. +* (Aura Designer) Fix error spam from the health bar overlay, most noticeable in follower dungeons and other content where players join and leave often. +* (Interface) Fix spells with hidden tooltips (like Strength of the Black Ox) showing an empty box in the Aura Filters spell list — they now show their name and spell ID. +* (Aura Designer) One spell picker everywhere — adding indicators, group spells and triggers all use the same spell database picker as Aura Filters, with search, class and category filters, and add-by-ID. +* (Aura Designer) Every indicator has a show/hide eye — turn one off without deleting it. +* (Aura Designer) The editor header is condensed to a single row; Sound Alerts moved to the Global tab. +* (Aura Designer) **Sound alerts do more than announce a new buff.** Each indicator's Sound section can now play a separate sound when a tracked buff **drops off** and when it **gains a stack**, alongside the existing on-applied alert (which now has its own on/off toggle). The sound system was also rebuilt on 12.1's new aura-sound engine so alerts keep working on the current build. The buff-dropped and stack-gained triggers need the current game build; the section notes this if your client doesn't have them yet. +* (Profiles) Custom filters and your category tweaks travel with profile exports — including filters linked in the Aura Designer. +* (Profiles) Custom filters picked inside a raid auto layout's settings are handled everywhere the rest of the profile is: deleting a filter now removes it from every layout (including the active one), exports carry filters only a layout references, and imports re-link them instead of pointing at the wrong filter on the receiving account. +* (Aura Filters) **New: share a single filter.** Filters can now be exported and imported one at a time, so you can send someone one filter instead of your whole profile. "+ Import Filter" sits under "+ New Buff Filter" in the list, and Export is in the button strip below it. Built-in presets can be shared too — they arrive as a custom filter holding whatever spells you had enabled. If you import a filter you already have, DandersFrames says so and lets you pick between keeping yours or adding a copy. +* (Frames) The Out of Range "Text Alpha" slider now also governs pet frame text and the test-mode preview — those still followed hidden per-element values no control could change. +* (Interface) In Aura Filters, a spell added by ID now shows its real name and icon (resolved from the game) instead of a bare "#id" and a question mark. It's tagged "not in database" so you can spot spells that aren't part of a built-in preset yet; only a genuinely invalid ID still reads "unknown ID". +* (Interface) Fixed the Aura Filters page's Add, Add from Database, Rename and Delete buttons filling with a solid colour on hover after switching between a built-in preset and a custom filter, and gave their disabled-state tooltips a consistent two-line layout. +* (Frames) DandersFrames no longer overwrites Blizzard's own raid-frame dispel indicator setting at every login — a leftover from a long-removed option that kept forcing a value you couldn't change. +* (Interface) The Aura Filters add-by-ID box now rejects over-long spell IDs like the spell picker does, and a spell picker first opened during combat regains its Escape-to-close afterwards. +* (Profiles) Role colours (tank/healer/damage) now travel with profile exports. +* (Aura Designer) The global Default Frame Level and "Draw above frame border" were marked as not yet available for part of the 12.1 alpha — they looked live but weren't applied. The z-order pass they were waiting on has since landed (below), so they work again. The Frame Strata dropdowns are gone rather than restored — see the same entry. +* (Interface) Removed the Resurrection icon's "Pending Text" box and the "My Buff Indicators" export category — neither had done anything (the text was never rendered; the category exported no settings). +* (Auras) Fixed an error ("Attempt to access forbidden object") from the dispel overlay when zoning out of an instance mid-run — the overlay's layout pass could measure a protected aura-button rect during the transition; it now measures safely and falls back to defaults for that one pass. +* (Bars) Fixed a gap on either side of a "Match Health Bar Width" resource bar at frame border sizes 1-3 (most visible with a non-zero Frame Padding). The bar now sits flush inside the border, and stays aligned with it at any UI scale. +* (Frames) Fixed grouped raid frames staying invisible after converting a raid to a party and back until a /reload — the frames were alive underneath, but their group anchors were never restored on re-entering the raid. +* (Aura Designer) Fixed the editor's preview icons vanishing after switching to another settings page and back — from the second round trip on they stayed missing until the window was reopened or an indicator was toggled. +* (Aura Designer) **Fixed new group names counting up forever.** Creating and deleting groups left the next one called "Group 7" on an empty designer, because the label came from a counter that never reset. New groups are now numbered from the groups that actually exist, so an empty designer starts at Group 1 again (a gap is still left where a group was deleted, so the list stays in order). Applies to Group, Filter Group and Debuff Group names; the internal ids are unchanged, so existing indicator links are unaffected. +* (Frames) **Fixed the Reduced Max Health overlay disappearing in Mythic+ and raids** — exactly where max-HP reductions happen. In restricted content the game returns the modifier as a secret value, and the overlay was hiding rather than touch it; the value can be fed straight to the bar (the same way health bars render at all on 12.1), so it renders again. Health-bar clipping stays off on that path as a safety measure. +* (Auras) **Fixed Pixel Perfect on the new aura engine.** With Pixel Perfect on, aura icon borders could render unevenly — a fat edge one side, a thin or missing edge another, a visible gap between border and icon, and adjacent icons in a row each looking different. Borders now snap to whole screen pixels in their actual rendered space (including scaled icons), icon sizes and spacing quantize to whole pixels so rows stay uniform, and each icon row is nudged onto the pixel grid — restoring what Pixel Perfect delivered before the 12.1 rework, across the buff/debuff/defensive bars, Aura Designer indicators and groups, and missing-buff icons. +* (Profiles) Fixed a doubled "v" (like "vv5.0.0") in the version shown after parsing a profile import string. +* (Auras) Fixed an error storm ("Attempt to access forbidden object") from the dispel overlay's out-of-range fade during combat in restricted content — the fade wrote alpha directly on the game's protected aura buttons; it now dims the overlay through its own wrapper, same result without touching protected objects. +* (Auras) **Fixed random buffs (food, flasks, everything) flooding spell-ID-filtered bars for cross-faction group members.** The game only honours spell-ID aura filters for units you can currently assist — and a cross-faction group member only counts as assistable inside instanced content (dueling a party member does the same). Outside, the filter silently stopped applying and every filtered surface could show wrong buffs on that player — the buff and defensive bars, missing-buff icons, and Aura Designer indicators (including "My Buffs" ones, where a buff you cast in the instance could keep showing in the wrong indicator afterwards). All of these now hide for that unit until they're assistable again, re-checking after loading screens and as member data settles — no more garbage rows for cross-faction friends in the open world. +* (Auras) **Fixed "Only My Buffs" showing other players' buffs on a party member who's in a different instance than you.** The "my buffs" filter needs each aura's caster, which the game can't attribute for a unit outside your instance — so it quietly fell back to showing that buff from *everyone* (the spell-category filters kept working, so it looked like only that one option had broken, and `UnitCanAssist` stayed true so the cross-faction fix above didn't catch it). Those pools now hide for a unit who isn't in your visible world, the same way cross-faction pools do. Same-instance members far out of range are unaffected. +* (Auras) Renamed two built-in filter presets: "Per-spec Healing" is now **Healing**, and "Raid Defensives" is now **Raid Cooldowns**. Your selections and per-spell toggles carry over unchanged. +* (Frames) Clicks on party and raid frames no longer redirect to a member's vehicle or pet slot while the game flags them as being in a vehicle (sitting on housing furniture counts) — a legacy behaviour Blizzard's own frames dropped, which could target the wrong unit or open a pet menu. +* (Auras) **Custom dispel colours are back.** Set your own colour per dispel type (Magic, Curse, Disease, Poison, Bleed/Enrage) in a new **Dispel Colors** section on the Colors page — one shared account-wide set. Both the debuff-icon border (via "Color by Dispel Type") and the Dispel Overlay follow it directly, with no separate mode to switch on. Defaults are the game's own dispel palette, so nothing changes until you edit it, and Reset restores the game colours. Each feature links to the Colors page. +* (Auras) Dispel colours now apply the moment you pick them — changing one no longer rebuilds the aura icons, so the brief flicker on every colour tweak is gone. +* (Auras) Fixed test mode showing the game's dispel colours instead of your own — the preview was overriding your Dispel Colors, so custom colours looked like they only worked on live frames. +* (Auras) **New: Optional Debuffs.** Hide nuisance debuffs from the debuff bar, on the Aura Filters page under **Debuffs → Optional Debuffs**. Everything in the list shows by default; unselect one to hide it. The Sated/Exhaustion (post-Bloodlust) family is hidden by default, with BG/Dungeon Deserter, Ride Along and the Mythic+ **Challenger's Burden** available as opt-in hides. Buffs stay opt-in via the buff presets and custom filters, so this list is debuffs only; and only debuffs the game leaves non-secret can be hidden (a 12.1 limitation — encounter debuffs stay visible). +* (Auras) **New: Debuff Filters.** The debuff bar is now filtered by categories on the Aura Filters page — Boss, Role, Priority, Crowd Control, Raid and Dispellable (by you or by anyone). "Show All Debuffs" keeps the old behaviour. +* (Auras) **The debuff bar now defaults to "All Debuffs".** Blizzard's category filters aren't exhaustive — some debuffs are tagged with none of the categories and slip through even with every one enabled, so "All Debuffs" is the only complete option. Category mode is still available; a caution note under it now explains the gap (the categories are Blizzard-defined and can't be widened) — the old "use the categories instead" nudge is gone. Existing profiles keep their current setting; Reset Page picks up the new default. +* (Auras) The "All Dispellable" debuff filter mode now actually filters — it previously showed every debuff. +* (Auras) New "Hide Long Debuffs" filter: hides debuffs over a duration threshold, with an option to keep boss, role and priority debuffs visible. +* (Auras) Buff and debuff filter preferences have been reset to new defaults as part of this rework. +* (Profiles) A settings page's Copy, Sync and Reset buttons no longer touch settings owned by a different page. +* (Auras) **New: Buff Filters.** The buff and defensive bars are now filtered by curated spell categories — healing, raid buffs, defensives, movement and more. Pick the buff bar's categories on the Aura Filters page and the defensive bar's on the Defensive Icon page. +* (Interface) **Aura Filters now shows what's inside a filter.** The page lists every filter on the left with its own checkbox, and selecting one shows its full spell list on the right — so choosing which filters a bar uses and editing what those filters contain happen in one place, instead of the filters being an opaque name you switch on and hope about. Every spell in a built-in filter can be unselected, and you can build your own filters, adding spells by ID or from the spell database. Buffs and Debuffs are tabs. +* (Auras) Filter presets start with passive "noise" auras unselected — mastery procs and auto-applied side effects. Everything stays listed on the Aura Filters page, one click to bring back. +* (Auras) Spell database updated with the latest harvest — two new filter categories, Tier Set Auras and Racials, plus dozens of new spells and placement fixes. +* (Tooltips) Aura tooltip Enable toggles now apply immediately. Anchor/Offset and Disable in Combat can't apply to 12.1 aura icons (game limitation) and are marked in place — they still work for Frame and Binding tooltips. +* (Aura Designer) The editor's preview canvas renders through the real 12.1 engine — what you see while styling is exactly what ships to the frames, including out-of-range fading. +* (Auras) **New: editable duration colours.** "Color by Time Remaining" now uses colour breakpoints you set yourself, in a new **Color by Time** section on the Colors page — each stop is one row with its colour, the range it covers and the point it takes over, and you can add or remove stops freely. Shared across buffs, debuffs, defensives and the Aura Designer, and defaulting to a bright green → gold → orange → red ladder. +* (Auras) **Colour by seconds remaining, or by percent of the aura's duration.** The Color by Time editor has a tab for each — they're separate colour ladders, so "under 3s" and "under 25%" can look different — and the defaults now line up, both splitting into even quarters. A percent ladder suits mixed durations, where a fixed number of seconds means very different things on a 6-second shield and a 5-minute buff. +* (Auras) **Duration text can blend smoothly between the stops.** A "Blend Colors Smoothly" toggle makes the countdown fade through the ladder instead of snapping band to band. This applies to duration text only, account-wide; the expiry Border and Tint reveals always step, which the editor now shows you directly — a "How this renders" legend previews each one exactly as it will appear, with that consumer's own controls on its row. +* (Aura Designer) **Each expiry alert picks its own seconds-or-percent.** The threshold beside every indicator's Expiry Warning has an s / % toggle on the value box, so one indicator can warn "below 5s" while another warns "below 30%", independent of what the duration text uses. +* (Auras) Every "Color by Time Remaining" setting — on the buff, debuff and defensive pages, and throughout the Aura Designer (including the new expiry Border/Tint reveals) — now carries a "Colors page" link that jumps straight to that shared breakpoint editor, expands it if collapsed, and highlights it. +* (Aura Designer) Test mode previews the Aura Designer through the real engine — every placed indicator renders with its configured spell, styling and animations, exactly as live. +* (Interface) **Border animations are now entirely DF's own.** Four new effects — DF Chase, DF Flash, DF Pixel and DF Proc — join DF Pulsate, DF Dash and Blink; the old library-backed effects are retired. +* (Auras) **Animated borders are available on the Missing-Buff icons**, with the full DF animation set. The animation driver is now shared, so animated borders are lighter across the board. +* (Auras) Border animations aren't offered on the aura icons themselves — buff, debuff, defensive and Aura Designer indicators. On 12.1 the game hides aura data in combat and forbids addons from touching aura icons while it's hidden, so an animated border there would break; those borders are static. Frame borders, the Missing-Buff badge and targeted-spell highlights are unaffected and still animate. +* (Auras) Out-of-range and dead-unit fading works on the 12.1 aura displays again — live and in test mode. +* (Auras) Aura text is sharper and countdown timers stay put — text now renders at its true size instead of being scaled up (which softened it, more so at larger sizes), and centred timers no longer drift or wobble as they tick down or when a text outline or shadow is enabled. +* (Aura Designer) Icon and square indicators have refreshed defaults — **DF Roboto SemiBold** with a drop shadow, slightly larger duration text and the stack count tucked into the bottom-right corner (was Friz Quadrata, no shadow, centred stacks) — matching the buff and debuff bars. Indicators still on the untouched old defaults are updated automatically; anything you've already customised is left alone. Newly placed indicators start with duration text coloured by time remaining (as they always have), and new or reset profiles have it on out of the box — existing profiles and already-placed indicators keep their current setting. +* (Aura Designer) The spell picker shows one unified list — the separate "Inferred Tracking" section is gone; every tracked aura now works the same way. +* (Aura Designer) Filter and debuff group boxes in the editor preview show greyed example icons, sized and arranged to your layout settings. +* (Aura Designer) Filter and debuff groups can be styled like placed indicators — Appearance, Border, Duration Text and Stack Count sections — and the preview's example icons wear your styling live. +* (Interface) The deprecated My Buff Indicators feature has been fully removed — the missing-buff display and the Aura Designer cover its use cases. +* (Internal) **The pre-12.1 aura pipeline is fully removed** — every aura surface now runs exclusively on the 12.1 engine. If any aura display behaves differently than before, please report it. +* (Dispel) Fixed the Top/Bottom/Left/Right Edge gradient styles rendering as health-shaped fragments after "On Current Health Only" had been active. +* (Auras) Fixed a doubled gap between filter blocks when more than one filter is enabled — rows are uniformly spaced. +* (Auras) **Centered growth works again** — rows set to grow from the center stay centered as icons appear and disappear, in combat too. The dropdown is now labelled **Grow**, with a **From Center** option. +* (Test Mode) **Test mode previews buffs, debuffs, defensives and missing buffs through the real 12.1 engine** — actual container rows with live-true layout, borders, fonts, animations and tooltips. While test mode is open, the game's own aura displays temporarily show sample data; everything restores when it closes. +* (Auras) The debuff "Color by Dispel Type" ring is now a flat square line matching your border's style and thickness, instead of the game's rounded ring art. +* (Borders) Fixed switching a border from Gradient back to Solid sometimes leaving the gradient stuck until a /reload on aura icon borders. +* (Profiles) Fixed profile resets not sticking for the Aura Filters — filters no longer flip back to an old preset after the next /reload. +* (Profiles) Fixed "Reset Profile to Defaults" leaving the Aura Designer and Text Designer untouched — a reset now rebuilds both designer libraries to their defaults. +* (Text Designer) Fixed the Text Designer going blank until a /reload after a profile reset. The default health text stays current/max. +* (Text Designer) The Texts and Text Groups tabs now show a live count of their enabled elements. +* (Interface) The settings window now defaults to **DF Roboto SemiBold**. Profiles still on the old default switch once — pick Friz Quadrata again to keep it. In-game frame text is unchanged. +* (Integrations) Removed the Click-Through Icons options — 12.1 aura icons are always click-through, so the toggles no longer did anything. +* (Class Power) Removed the Class Power Pips display and its settings page, along with its Test Mode toggle. +* (Performance) Disabling the Blizzard party/raid frames now fully shuts them down instead of just hiding them, stopping their background work. Toggling this (or Show Side Menu) now prompts for a UI reload. +* (Dispel) **The Dispel Overlay is now one unified system driven by the game's aura engine — and it covers boss debuffs (private auras) natively.** The old source selector is a single Enable toggle (existing settings migrate), colours come from the game's dispel palette, and everything else — border, glow, gradient, blend, darken, icons, pulse — stays yours to style, live and in combat. Also fixed a Lua error when opening Edit Mode with the overlay active. +* (Auras) Opening WoW's Edit Mode no longer floods the aura rows with random sample icons — rows hide during Edit Mode and restore when it closes. +* (Profiles) Refreshed the default profile's look — new installs and profile resets only. Tighter frames, buff borders on, Missing Buffs on by default, a cleaner pet style, and the Big/External Defensive filters no longer default on. +* (Profiles) Fixed several border settings (Hide Intro Flash, border Colour Source) silently reverting on profile export/import and party↔raid copies. +* (Test Mode) **Rebuilt the Quick Presets** (Static / Combat / Healer / Full). Each preset now sets every toggle, so they're reproducible — "Full" is genuinely everything — and applying one repaints the preview immediately. +* (Test Mode) Fixed the party-only Targeted List and Personal Targeted settings leaking into the raid preview. +* (Test Mode) The Targeted Spells and Targeted List toggles are greyed out in raid test mode — both are party-only features. +* (Boss Debuffs) **Boss debuffs are now part of the regular debuff row** — including private auras, with duration, stacks and dispel colouring. The separate Boss Debuffs display and its settings page are retired. +* (Missing Buffs) **Missing Buffs now work on 12.1 — including in combat and Mythic+.** The feature is display-only and driven by the game engine, so no combat delay and no Mythic+ blackout. In manual multi-buff mode every missing buff shows its own icon, and missing buffs now show for NPC party members too. +* (Defensives) Fixed a defensive appearing twice when it counts as both personal and external (e.g. Guardian Spirit) — externals someone cast on you sort first. Also removed a duplicated pair of Duration Offset sliders. +* (Auras) Debuff settings caught up with the buff row: Duration Format, Duration and Stack colours, filters and Sort Order all drive the new rows; settings the rows can't provide are marked in place. +* (Click-Casting) Fixed right-click target/menu erroring with click-casting enabled — a 12.1 game bug; the workaround retires automatically once the game fixes it. +* (Auras) **Debuffs now render through the 12.1 aura engine** — the last row on the legacy path, which could show nothing in combat. Layout, borders, text and sort work like the buff row, and a new "Dispel Border Inset" slider positions the dispel ring. +* (Auras) Buff "Sort Order" works again — Time Remaining and Alphabetical apply natively, in combat too. +* (Auras) New "Hide Long Buffs" filter: hides buffs whose total duration exceeds a threshold — e.g. hour-long food buffs. Permanent buffs are also hidden while this is on. +* (Auras) **Masque skinning support has been retired** — the new 12.1 aura icons can't be skinned by third-party addons, so the Masque option on the Integrations page no longer did anything and has been removed. DF's own border settings always apply. +* (Auras) Texture-style aura borders no longer scatter, lose their colour or vanish until a /reload — and fine border detail stays crisp at small icon sizes. +* (Auras) "Color by Time Remaining" works again on duration text, ticking in combat — fixed time steps (red under 5s, orange under 15s, yellow under a minute). +* (Auras) Buff and defensive icon text share one styling system, adding Duration and Stack colour options. The "Short" format no longer shows a stray space. +* (Auras) Fixed Icons Per Row wrapping one icon early. +* (Auras) Aura rows honor your layout settings again — anchor, direction, wrap, offsets, spacing, size and scale were previously ignored. +* (Auras) Aura settings apply in real time again — sliders re-flow the row live during the drag. +* (Interface) Settings the 12.1 rows can't provide are marked in place: "Min Stacks to Show", "Icons Per Row" on vertical rows, and "Border Animation" on buff icons. +* (Auras) Fixed an in-combat error that permanently froze a frame's aura updates until reload. Stack counts now use the built-in display (shown at 2 or more). +* (Auras) New buff duration format option (Number / Short / Full). +* (Aura Designer) **The Aura Designer is fully rebuilt on the 12.1 aura system.** Every indicator type works again — and keeps working in combat — including animated borders, sound alerts and "Show When Missing". The few effects 12.1 can't support stay marked "Blizzard limitation" in the panel. +* (Aura Designer) Fixed layout groups stacking all their spells on one spot — grouped indicators arrange into their grid again, and a missing buff leaves its slot empty. +* (Aura Designer) Adding spells to a layout group now uses the full searchable spell picker — click Icon or Square to drop a spell in, and the picker stays open for adding several. +* (Aura Designer) **New: Other Buffs tab.** Track any buff on your frames no matter who cast it — for example, Power Infusion on anyone in your group — with the same indicators, effects and sound alerts. Your own class's spells now live under the "My Buffs" tab. +* (Aura Designer) New "Others Only" option on Other Buffs effects and filter groups: only show the effect when someone else cast the buff — your own casts still show on the regular buff bar. +* (Aura Designer) A spell can be tracked in one tab at a time — the picker shows where a spell already lives, so the same buff never renders twice. +* (Aura Designer) Layout groups and filter groups are available on the Other Buffs tab too, shared across all your specs. +* (Aura Designer) **New: Debuffs tab.** Give debuff categories their own icon rows on the frame — for example, crowd control in its own spot with its own icon size. Tick a group's categories, set its layout, and those categories leave the main debuff bar automatically. Each group has its own dispellable mode and "Hide Long Debuffs" option. + +* (Aura Designer) My Buffs now reacts only to your own buffs — another player's cast of the same spell no longer lights your indicators, effects, or groups. Use the Other Buffs tab to track a buff from anyone. +* (Auras) Each Aura Filters tab opens with a line saying what you can do there: buff filters are wholly yours to edit or replace, while the debuff categories are Blizzard's and fixed. The old Aura Filter Setup wizard has been removed; use the Reset Page button to return filters to their defaults. +* (Interface) Overrides now read the same way across the whole addon: auto layouts and Aura Filters use one coloured dot — with a hover tooltip saying what's overridden — in place of the old mix of stars and boxes, and every reset-to-default control shares one red icon style. +* (Auras) Tuned the default buff-filter presets: some spells now default on or off (for example Infusion of Light and Immolation Aura start off), a few non-aura entries such as totems and guardians were dropped, and several spells moved to more fitting categories. Your own per-preset enable/disable choices are not affected. +* (Auras) **Duration bars can colour by remaining time.** A new Color Mode on the Buffs, Debuffs and Defensive Icon duration-bar sections (and on Aura Designer group cards) adds three colour curves alongside the existing static colour: **DF Smooth**, which blends through the addon's expiry palette, **DF Stops**, which shows that same palette as four hard bands so the bar reads like stepped duration text, and **Classic**, a traditional green-to-red. The bar's colour walks the curve as the aura drains. Note this tracks the *proportion* of the aura remaining rather than a number of seconds, and the curve colours are fixed to the default palette — the duration *text* is what follows your own Color by Time breakpoints. Texture and Bar Color grey out while a curve is selected, since the curve supplies both. The default duration-bar gap is also now 1 (was 2). +* (Auras) **Fixed duration bars filling up instead of draining.** The bars were inheriting the game's default counting direction, which measures *elapsed* time — so a "duration bar" started empty and filled as the aura ran out, the opposite of what the setting describes. They now count remaining time and drain, as intended. Reverse Fill still mirrors which edge the bar drains toward. +* (Aura Designer) **Placed icon and square indicators now have a Duration Bar too.** Previously the duration-bar strip was only offered on Layout Groups; each individually placed icon/square now has its own Duration Bar section with the same options — enable, position, height, gap, Color Mode (Static / DF Smooth / DF Stops / Classic), texture, colours, and reverse fill. +* (Aura Designer) **Bar indicators gain a Color Mode.** The standalone Bar indicator now has the same **Color Mode** (Static / DF Smooth / DF Stops / Classic) as the other duration bars, in its Texture & Colors section. Pick a curve and the bar's fill reddens as the aura drains (Bar Texture and Fill Color grey out then, since the curve supplies both). This is a bar's proper "expiry colour" — the Expiration section on a bar handles only a Text/Glyph alert. +* (Aura Designer) **New Expiry Alert "Border" and "Tint" modes.** Alongside Custom Text and Glyph, a placed icon/square's Expiry Warning can now show a coloured **frame** (Border — in three thicknesses) or a **solid wash over the whole icon** (Tint) when the aura is about to expire. Either matches the icon size automatically (with an Inset to nudge it), takes an Opacity, and its colour can be a fixed pick **or** follow the same **Color by Time** breakpoints as your duration text — so it reddens as the aura runs down, in step with the numbers. On **bar** indicators the reveal offers a Text or Glyph alert only — a bar's *colour* expiry is its own fill via the Duration Bar's Color Mode (a DF Smooth / DF Stops / Classic curve reddens it as it drains), so a Border/Tint overlay isn't offered there. The Expiration panel has an Enable toggle and a Type dropdown, and shows only the controls that apply to the type you pick. Fully combat-safe (driven render-side, no reliance on reading the remaining time). + +* (Interface) **The settings window has been rebuilt on one shared foundation.** Every surface — the options pages, the Aura and Text Designers, the spell and icon pickers, the frame movers, Pinned Frames, Search and Test Mode — used to build its own boxes, hover states and row geometry, so they drifted apart over time and a change to the look had to be made in a dozen places. They all come from one factory now, so a box, a button or a row looks and behaves the same wherever you meet it. +* (Interface) **Fixed borders in the settings window thinning, looking uneven, or vanishing outright.** A one-pixel line can only be drawn cleanly when it lands exactly on your monitor's pixel grid; at most UI scales it lands between two rows and splits its ink across both, and at the faint greys this interface uses, both halves can fall below what a screen will show. It was worst while scrolling, where the line crosses the grid every frame. Borders are now drawn as textures the addon owns, thick enough not to care where they land, so they look the same at any scale and stay solid in motion. +* (Interface) **Consistent spacing throughout the settings window.** Section headers used to sit almost on top of the control below them, and the gap between two controls depended on which two they were — the same nominal gap varied by a factor of four across different pages. Spacing now comes from one set of numbers, measured rather than guessed. A run of checkboxes closes up so it reads as one list, while the gap to a different kind of control below stays full. +* (Interface) **Every settings page now follows one layout rule.** The left column holds structure and geometry (Settings → Layout → Size → Position); the right column holds styling (Appearance → Border → element extras). Pages that genuinely need the full width — Pinned Frames, Nicknames — still get it, and pages with no geometry/styling distinction at all (Tooltips, Settings, Sorting, Manage, Import/Export) balance instead. Applied across Pet Frames, Resource Bar, Missing Buffs, Defensive Icon, Group Labels, Frame, Dispel Overlay and Aura Filters. Three boxes that had crept to full width on ordinary two-column pages (Pet Frames' top pair, Fading's Health Threshold, Pinned Frames' Auto-Populate) are back to column width — a full-width box also drags both columns down to align, so they were skewing the pages they sat on. +* (Interface) **Fewer things to expand before you reach a setting.** Collapsible headers now mean one thing — "here is another one of these", like each icon on the Icons page or each highlight on Highlights. Headers that merely named a category (Layout, Appearance, Settings) are gone from the Buffs, Debuffs, Personal Targeted and Targeted List pages. On the **Icons** page this went a level deeper: each icon's Settings, Appearance and Position boxes were themselves collapsible on top of the icon section that already collapsed, so opening "Leader Icon" gave you three more things to open. Those boxes are now always open, and the section headers match the width of the boxes beneath them. +* (Interface) **Every confirmation and name prompt is now DandersFrames' own dialog.** All 26 of them used Blizzard's popup, which can't be themed to match the rest of the addon, can't hold a text field, and treats Escape as "hide this box" rather than "cancel what it was asking about". Renaming a profile, deleting a filter, importing a string and everything like them now go through one themed dialog that cancels properly. +* (Interface) **Tooltips work the same way everywhere.** They used to attach three different ways, appear from three different places, and follow three different anchors depending on which control you hovered. There is now one system: hover a setting's **label** to see its tooltip, and it appears just above the cursor rather than on top of the control you were about to drag. More settings have them too, though deliberately not all — a slider labelled "Max Buffs" explains itself, while "Inset" reads as jargon and now says what it does everywhere it appears. +* (Interface) **Settings that don't apply now grey out instead of disappearing.** Turning a feature off on the Targeted List, Personal Targeted, Buffs, Debuffs, Missing Buffs and Defensive Icon pages used to remove its controls from the page entirely, so the page reflowed and you couldn't see what you'd be turning back on. They stay put and grey, which is what the rest of the addon already did. Targeted List's border settings also move into their own Border box — they were rendering inside the bar-style group. +* (Auto Layouts) The page now shows a clear disabled state when the feature is off, matching the Aura and Text Designers. It was previously possible to add and edit layouts while Auto Layouts was switched off, with no indication they wouldn't be used. +* (Interface) **Targeted Spells has been removed.** The group/on-frame half of the feature depends on aura information 12.1 no longer exposes to addons, so it is gone along with the setup wizard that configured it. **Personal** targeted spells and the **Targeted List** are unaffected and stay where they are. +* (Interface) **"Preset" and "Template" now mean different things.** A saved Aura Designer or Text Designer setup is a **Template**; "preset" is reserved for the built-in aura filters and the export/test quick-picks. Your saved data is untouched — the underlying keys deliberately still say preset, because they travel in export strings and renaming them would break every string already shared. +* (Aura Designer / Text Designer) **Copy & Sync is gone from both designers; sharing is shown on the template bar instead.** These two pages differ from every other mode-specific page: they own a single setting — which template you're using — and the dropdown directly above the buttons already sets it. Two controls writing the same value is what made "Stop Syncing Only" appear to do nothing. The template bar now marks a shared template, names who else is using it (Party, Raid, Auto Layouts, Pinned Frames) and says that edits apply there too. To split a shared template off for one mode, use **Duplicate** — which also lets you name the copy. +* (Interface) **Aura Filters: clearer names and readable rows.** "Other Buffs" is now **Any Buff**, the buff sections are named for what they hold, and New Filter sits in the section it creates into. Every list uses the same checkbox, and a selected box means one thing throughout: this shows on your frames. Spell rows lost their per-row "i" button — the spell IDs it showed now ride along with the tooltip you already get from hovering the icon or name, so spells added by ID that the database doesn't recognise finally have a tooltip at all. Remove buttons on a hovered row no longer dissolve into the highlight, and both panels use the window's full height. +* (Interface) **A filter now tells you where it's used.** Under the selected filter's name: a state dot and the places it applies — "On · Buff bar (Party), Defensive Icon", or "Off · not in use". Editing a filter changes it everywhere, so this answers, before you touch anything, what else you're about to affect. It reads the live setup rather than a fixed list, including Aura Designer **filter groups** that link the filter, and it follows use *anywhere* — so a filter the Defensive Icon uses reads green even while it's unselected for the buff bar, which is correct and worth knowing. Hovering it explains the three scopes that make filters look broken after a Party/Raid switch: which filters are selected is per mode, a built-in filter's spells are per profile, and a custom filter's spells are shared by every profile on the account. +* (Interface) The debuff-category caution ("only All Debuffs shows every debuff") moved out of the page banner and into the filter list, directly under the All Debuffs switch, and only appears while that switch is off — next to the control it's about, and no longer displacing the page's own explanation. +* (Auras) **Buff and debuff ordering and limits moved to the Buffs and Debuffs pages.** Sort order, "My Auras First", reverse order, the maximum-duration cap and "Hide Permanent Auras" now sit in an **Order & Limits** box on the page whose bar they affect, alongside the rest of that bar's behaviour, rather than on the filter page. The Dispellable **Mode** dropdown moved with them. +* (Interface) Aura Filters gains the addon's standard **Copy / Sync / Reset** row, so a set of filters built for Party can be shared with Raid — which matters because filter *selection* is per mode, even though what a filter contains is not. +* (Interface) The Aura Filters search box now carries the same magnifier icon as the addon's main search, and the **Add from Database** button sits beside the Spell ID box, so both ways of adding a spell are on one row. The filter list is wider as a result. +* (Interface) The **Icons** page no longer nests collapsible sections inside collapsible sections. Each icon's settings open and close as one, instead of expanding to reveal three or four more things to expand, and the page is the same width as every other single-column page. +* (Interface) **The Targeted List page's box names say what they are** — "Show" is now "Show Text", "Font" is "Text Font", and four boxes that just said "Position" now name what they position. +* (Interface) The Tooltips page's boxes are paired by what you hover — Frame with Binding, Buff with Debuff, Defensive with Resurrection — so the two you'd want to keep in step sit side by side. A stray alignment marker that opened a ~70px hole in the left column is gone. +* (Interface) Fixed the settings menu's hover highlight blinking off between rows — a two-unit dead band between each pair of rows meant the highlight had nothing to sit on as the cursor crossed it. Also fixed the whole menu list sitting a fraction of a pixel off the display grid, which softened all 42 rows, and the scroll area clipping its contents against a fractional edge, which cut into any box near the top of a page. +* (Interface) Fixed drag-to-reorder lists (auto layouts, indicator order) accumulating enough positioning error down the list that adjacent row borders merged into a single line. +* (Interface) Sliders now fill their row like dropdowns instead of stopping short of it, and override lists show setting names rather than raw internal keys. The "See Also" links park at the bottom of a short page, where they read as the footer they are. +* (Frames) **Frame Level settings apply immediately again.** Changing a Frame Level did nothing until a `/reload`, because a rebuild never re-applied it. The sliders also show the level that's actually in use, and rows no longer overlap each other. The per-element **Frame Strata** dropdowns have been removed — z-order now runs through a single path rather than three copies that could disagree, and strata isn't something you need to set per element for that to work. The guidance that used to sit as 21 blocks of body text across the settings pages is now a tooltip on the control itself. +* (Interface) The "not yet available on 12.1" frosting is gone from the settings pages. Every control it covered is now either working or removed — nothing is left in the interface marked as blocked. +* (Test Mode) **Fixed frames staying stuck on test data until a `/reload`.** If test mode ended because you entered combat or changed zone — rather than because you switched it off — the fake aura data was never handed back, so your frames kept showing it. +* (Profiles) **Fixed a brand-new profile being run through the one-time upgrade steps meant for old ones**, which have nothing to upgrade and the wrong shape to upgrade from. Fixed one of those steps deleting Aura Designer configurations outright while re-scoping them per spec. +* (Auras) Fixed a dispel pulse continuing on two of its three groups after it should have stopped, a failed aura-border attachment giving up instead of retrying, and a stale cache of frame attributes surviving a rebuild. +* (Frames) **Fixed dead and offline units not updating their status text or their missing-buff badge.** A unit that died kept whatever its frame was showing a moment earlier — no "Dead" or "Offline" status, and a missing-buff badge still asking you to buff someone who could no longer receive it. Both corrected only when something else forced a full refresh, often when you died as well, which is what made it look like your own state mattered. Neither check was wrong; neither was being re-run. Most visible in follower dungeons, where companions go down often. +* (Profiles) Fixed Copy, Sync and Reset Page quietly leaving some settings behind on seven pages, with nothing to say they'd been skipped. Fading was the reported case. +* (Internal) Code cleanup: removed unreferenced functions, orphaned locale strings and permanently-disabled blocks. No behaviour change. +* (Auras) **New: Important Debuffs.** Boss, role and priority debuffs can now stand out in the normal debuff bar without moving them somewhere else — make them larger and mark them with a warning badge, so the debuff that matters reads at a glance among the rest. The new **Important Debuffs** section on the Debuffs page has the size step, badge size, corner and offset, and colours for both the badge and its mark. Off by default. +* (Auras) **Targeted List no longer loses the opening cast of a pull.** "Hide Out of Combat" was checking whether the caster was in combat before checking who the cast was aimed at — and a mob whose opening move is a cast isn't flagged in combat yet, so the cast you most want to see was dropped. The setting defaults on, so this happened out of the box (measured at 10 lost casts in a single 19-minute session). It still hides idle NPC chatter, but no longer costs you openers aimed at your group. +* (Auras) **Targeted List now picks up casts that were already in progress.** Walk into range, turn the camera, or have a mob join a pull mid-cast and that cast produced no bar at all — it only appeared on the mob's *next* one. Personal Targeted Spells always handled this; the list now does too. +* (Auras) **New: "Show Offscreen Nameplates" on the Targeted List and Personal Targeted Spells pages.** Both features can only see a cast if the caster has a nameplate, so with WoW's offscreen-nameplate setting off, an enemy behind you produces no cast bar — confirmed in game with a targeted, visibly-casting mob that wasn't picked up until you turned to face it. The checkbox changes that Blizzard setting directly, so it affects all nameplates and not just cast detection; the tooltip says so plainly. +* (Aura Designer) **Frame Level sliders now show their real value.** The Aura Designer reported a lower level than it actually rendered at, so layering it against the defensive icon or the aura bars meant guessing — the defensive icon had to be pushed to 85 to sit on top of something that read 30. Every level is now the absolute value it says, and the Aura Designer's default returns to 40 (existing profiles are migrated). `/df debug zorder` also covers the Aura Designer now, printing the level you set beside the level it renders at. +* (Aura Designer) Fixed two square-indicator bugs: "Hide Cooldown Swipe" still left a yellow sweep animating over the square, and "Hide Icon" turned a square back into an icon. +* (Aura Designer) **Fixed Expiry Alert glyphs drawing underneath the icon they belong to** — on the frames, in test mode and in the editor's preview canvas. Also fixed an expiry alert appearing on its own, with no indicator beneath it, when one was configured on an indicator set to "show when missing" (two settings that contradict each other, so the alert is no longer built there at all). +* (Test Mode) **The preview now updates as you drag.** Buff, debuff, defensive and missing-buff previews only refreshed when you released a slider — and some changes needed test mode toggled off and on before they showed at all. Every container surface now follows changes live while you drag, the way the Aura Designer's preview already did. +* (Interface) **Border thickness now means what it says on textured borders.** DF Glow, DF Bevel, DF Inset and DF Double only ever drew about a quarter of the thickness you asked for: the line inside each texture filled a fraction of the space allotted to it, so setting thickness 8 gave you roughly 2 pixels where a solid border gave 8. All four have been redrawn to fill properly, so a textured border now carries the same weight as a solid one at the same setting, and the corners are drawn to match the edges they join. Note: your existing borders will look noticeably thicker at the same number — lower the thickness to taste. +* (Interface) **The Debug Console is now one system.** Every trace in the addon routes into a single console with named categories, so capturing a problem means turning on the relevant category and reproducing it — rather than hunting for whichever per-feature flag happened to cover that code. Logs persist across `/reload` and can be exported as text, which is the thing to attach to a bug report. Noisy categories stay off unless you ask for them, and every category is listed before it has logged anything, so you can pre-disable the chatty ones and stop the trace you actually want being pushed out by the line cap. The old per-feature debug flags are gone — six of them had already stopped doing anything. +* (Interface) **One debug command form: `/df debug `.** The debug commands are down from 36 to 25, with three overlapping pairs merged into single commands (auras, dispel, headers) and developer-only commands no longer listed on normal builds. Note: the standalone one-word `/dfsomething` commands are no longer registered. They put around 25 DandersFrames commands into the global slash namespace, where they collide with other addons and clutter every slash autocomplete, to document a spelling nobody needed twice. A diagnostic is now typed in full — `/dfpixelcheck` becomes `/df debug pixelcheck`, and likewise for the rest — so any macro using a one-word form or the short `/df pixelcheck` spelling needs updating. Everyday commands (`/df test`, `/df lock` and so on) are unchanged. `/rl` is kept, since it has no `/df` form to fall back on. + +### Bug Fixes + +* (Click Casting) **Fixed hover keybinds never working on frames that appear after you open the settings window** — opening the Click Casting options replaced the internal frame that owns hover bindings with a piece of the settings panel, so every frame created from then on (arena and boss frames, nameplates, frames for people joining your group) silently never got its keyboard bindings, and the automatic repair could not fix it either. A reload was the only way back. (by Krathe) +* (Click Casting) Fixed keyboard binds going dead on every frame for a whole fight when combat interrupted a binding refresh — typically zoning into a raid and a pull starting seconds later. (by Krathe) +* (Click Casting) Fixed hover keybinds dying for the rest of the session on some frames after visiting player housing. (by Krathe) +* (Click Casting) Fixed cast-on-down silently switching itself off on Blizzard's own raid and party frames after every roster change. (by Krathe) +* (Pet Frames) Fixed pet frames never appearing in arena (2v2, 3v3 and Solo Shuffle). Arena counts as a raid to the game, so pet frames were being looked for on the raid frames — which arena does not use. They now build alongside the arena frames, and follow your Party pet settings. (by Krathe) + +### Improvements + +* (Click Casting) Work that has to wait for combat to end — profile switches, frame registration, binding refreshes and keyboard-bind rebuilds — now runs from a single queue that always drains, instead of ten separate flags; a keyboard-bind refresh asked for during combat was previously dropped entirely. (by Krathe) +* (Click Casting) Binding-settle timers are now deduplicated so repeated events reuse one pending pass, a background rescan that re-armed itself forever now stops once its work is done, and two setup steps that gave up when combat outlasted their single retry now retry until combat ends. (by Krathe) +* (Click Casting) Applying and clearing bindings now tracks exactly which click attributes were written on each frame instead of a ~500-write blanket sweep, which also fixes the previous profile's action lingering on an unmodified mouse button until reload. (by Krathe) +* (Click Casting) **Removed:** the hidden per-binding "load only for these specs" field, which no settings screen could ever set and which could filter your binding list using unresolved spec data at login — per-spec click casting is unchanged via loadout-assigned profiles. (by Krathe) + + +### Known Issues (12.1 alpha) + +* The 12.1 aura displays are rebuilt on Blizzard's new container system and are under active testing — please report any case where buff, debuff, defensive or missing-buff displays stop updating, **especially in combat**. +* A debuff that counts as both a Priority debuff and a Boss/Role debuff can show one icon per matching filter when both are enabled — the only duplicate case the new filter system can't remove. "Show All Debuffs" avoids it. +* Dispel Overlay: a unit with dispellable debuffs of two different types can show both type icons overlapped (rare). +* Aura Designer text colouring is drawn as a cover over the text: it ignores the out-of-range text fade, and group parts with their own inline colours keep them. +* Dragging certain aura sliders can briefly stutter. +* Settings-window borders re-derive their thickness from DandersFrames' own **UI Scale** slider (top of the settings window). Changing WoW's global UI Scale instead won't re-derive them for pages already on screen — reopen the window, or nudge the addon's own slider, if a border looks off after doing that. +]===] diff --git a/DandersFrames/Core/ExportCategories.lua b/DandersFrames_Options/Core/ExportCategories.lua similarity index 99% rename from DandersFrames/Core/ExportCategories.lua rename to DandersFrames_Options/Core/ExportCategories.lua index 72e114eb..c7b73c34 100644 --- a/DandersFrames/Core/ExportCategories.lua +++ b/DandersFrames_Options/Core/ExportCategories.lua @@ -1,4 +1,7 @@ -local addonName, DF = ... +-- ☠ Companion addon: bind the parent table from the global, never `...`. +-- Resident consumers (selective import/export in Core/Profile.lua) load this +-- addon on demand before touching the tables below. +local DF = DandersFrames -- ============================================================ -- EXPORT/IMPORT CATEGORY DEFINITIONS diff --git a/DandersFrames_Options/DandersFrames_Options.toc b/DandersFrames_Options/DandersFrames_Options.toc index 647d711a..69dc00df 100644 --- a/DandersFrames_Options/DandersFrames_Options.toc +++ b/DandersFrames_Options/DandersFrames_Options.toc @@ -29,7 +29,18 @@ # AuraDesigner/UI/Options first (creates _uiState and _priv) # GUI/Pages/Options first (heads the page chain) +# Selective import/export category tables. Only Profile.lua's selective +# paths and the Modules page read these; both load this addon first. +Core\ExportCategories.lua + +# Changelog text (~180 KB string, GENERATED from CHANGELOG.md). Only the panel +# reads it, so it rides the companion; the stamped header stays resident. +Changelog.lua + # GUI parts used only by settings pages +# /dficons dev icon browser. Its slash registers on load; the /df debug +# dispatcher loads the companion on an unknown word, so first use still works. +GUI\IconLib.lua # Widget factories the live addon never calls, lifted out of the resident # GUI.lua / Widgets.lua. Loads first: the parts below build pages with them. GUI\SettingsWidgets.lua @@ -80,4 +91,6 @@ TestMode\TestMode.lua Debug\AuraExplorer.lua Debug\AtlasBrowser.lua Debug\MemoryTest.lua +# Profiler window; engine and SetScript hook stay resident in Debug/Profiler.lua +Debug\ProfilerUI.lua Debug\AttachmentScan.lua diff --git a/DandersFrames_Options/Debug/ProfilerUI.lua b/DandersFrames_Options/Debug/ProfilerUI.lua new file mode 100644 index 00000000..ce66c367 --- /dev/null +++ b/DandersFrames_Options/Debug/ProfilerUI.lua @@ -0,0 +1,672 @@ +-- ============================================================ +-- PROFILER WINDOW (split from Debug/Profiler.lua) +-- ============================================================ +-- The hooks, recording engine and chat reports are resident; this window is +-- pure presentation and loads with the settings companion. CreateUI already +-- gated on DF:EnsureOptionsLoaded before the split, so every path that opens +-- the window loads this file first. +-- ☠ Companion addon: bind the parent table from the global, never `...`. +local DF = DandersFrames +local Profiler = DF.Profiler +local format = string.format +local max = math.max +local ipairs = ipairs +-- Formatters shared with the resident chat reports. +local CommaNumber = Profiler.CommaNumber +local FormatMs = Profiler.FormatMs +local FormatUs = Profiler.FormatUs +local FormatBytes = Profiler.FormatBytes +local FormatPeak = Profiler.FormatPeak +local FormatElapsed = Profiler.FormatElapsed +-- Window state. The resident half reads these through Profiler.frame / +-- Profiler.UpdateUI (published below); nil until the window first opens. +local profilerFrame = nil +local dataRows = {} +local headerTexts = {} +local UpdateUI + +-- ============================================================ +-- UI +-- ============================================================ + +local ROW_HEIGHT = 18 +local MAX_ROWS = 30 +local FRAME_WIDTH = 786 -- +66 over the pre-Self-column width +local CONTENT_LEFT = 10 +local CONTENT_RIGHT = -10 +local HEADER_Y = -66 +local DATA_START_Y = -86 + +-- Column layout +local COLUMNS = { + { key = "name", label = "Function", width = 210, align = "LEFT" }, + { key = "calls", label = "Calls", width = 54, align = "RIGHT" }, + { key = "total", label = "Total ms", width = 60, align = "RIGHT" }, + -- Self = Total minus the time spent inside OTHER profiled functions this + -- one called. Sort by this to find where the work actually happens; sort + -- by Total to find which entry point owns a whole subtree. + { key = "selfMs", label = "Self ms", width = 62, align = "RIGHT" }, + { key = "avg", label = "Avg us", width = 52, align = "RIGHT" }, + { key = "max", label = "Max us", width = 52, align = "RIGHT" }, + { key = "peak", label = "Peak/tk", width = 52, align = "RIGHT" }, + { key = "mem", label = "Bytes", width = 56, align = "RIGHT" }, + { key = "pct", label = "%", width = 46, align = "RIGHT" }, +} + +local CONTENT_WIDTH = 0 +for _, col in ipairs(COLUMNS) do + CONTENT_WIDTH = CONTENT_WIDTH + col.width + 4 +end + +local function UpdateColumnHeaders() + for _, col in ipairs(COLUMNS) do + local label = col.label + if Profiler.sortColumn == col.key then + label = label .. (Profiler.sortDesc and " v" or " ^") + end + if headerTexts[col.key] then + headerTexts[col.key]:SetText(label) + end + end +end + +local function CreateRow(parent, index) + local row = CreateFrame("Frame", nil, parent) + row:SetHeight(ROW_HEIGHT) + row:SetPoint("LEFT", parent, "LEFT", CONTENT_LEFT, 0) + row:SetPoint("RIGHT", parent, "RIGHT", CONTENT_RIGHT, 0) + + -- Alternating background + row.bg = row:CreateTexture(nil, "BACKGROUND", nil, 0) + row.bg:SetAllPoints() + row.bg:SetColorTexture(index % 2 == 0 and 0.13 or 0.08, index % 2 == 0 and 0.13 or 0.08, index % 2 == 0 and 0.13 or 0.08, 1) + + -- Percentage bar (visual indicator behind text) + row.pctBar = row:CreateTexture(nil, "BACKGROUND", nil, 1) + row.pctBar:SetPoint("LEFT") + row.pctBar:SetHeight(ROW_HEIGHT) + row.pctBar:SetWidth(1) + row.pctBar:Hide() + + -- Column font strings + row.cols = {} + local xOffset = 2 + for _, col in ipairs(COLUMNS) do + local fs = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + fs:SetJustifyH(col.align) + fs:SetPoint("LEFT", row, "LEFT", xOffset, 0) + fs:SetWidth(col.width) + row.cols[col.key] = fs + xOffset = xOffset + col.width + 4 + end + + row:Hide() + return row +end + +-- Assign to the forward-declared upvalue so the combat handler can call it +UpdateUI = function() + if not profilerFrame or not profilerFrame:IsShown() then return end + + local elapsed = Profiler:GetElapsedSeconds() + local totalCalls = Profiler:GetTotalCalls() + local grandTotal = Profiler:GetGrandTotalMs() + + -- Status line + if Profiler.active then + profilerFrame.statusText:SetText(format( + "|cff00ff00Recording|r %s | %s calls | %sms CPU", + FormatElapsed(elapsed), CommaNumber(totalCalls), FormatMs(grandTotal) + )) + profilerFrame.toggleBtn:SetText("Stop") + else + if totalCalls > 0 then + local combatTag = Profiler.combatAuto and " | |cff00ff00Combat Armed|r" or "" + profilerFrame.statusText:SetText(format( + "|cffff4444Stopped|r %s | %s calls | %sms CPU%s", + FormatElapsed(elapsed), CommaNumber(totalCalls), FormatMs(grandTotal), combatTag + )) + else + local combatTag = Profiler.combatAuto and "|cff00ff00Combat Armed|r Waiting for combat..." or "|cff888888Ready|r Press Start to begin profiling" + profilerFrame.statusText:SetText(combatTag) + end + profilerFrame.toggleBtn:SetText("Start") + end + + -- Data rows + local results = Profiler:GetSortedResults() + + for i = 1, MAX_ROWS do + local row = dataRows[i] + if not row then break end + + local r = results[i] + if r then + -- Color based on cost share + local cr, cg, cb + local barR, barG, barB + if r.pct >= 25 then + cr, cg, cb = 1, 0.5, 0.5 + barR, barG, barB = 0.5, 0.15, 0.15 + elseif r.pct >= 10 then + cr, cg, cb = 1, 1, 0.6 + barR, barG, barB = 0.4, 0.4, 0.1 + else + cr, cg, cb = 0.7, 0.9, 0.7 + barR, barG, barB = 0.15, 0.35, 0.15 + end + + -- Percentage bar width + local barWidth = max(1, (CONTENT_WIDTH - 4) * (r.pct / 100)) + row.pctBar:SetWidth(barWidth) + row.pctBar:SetColorTexture(barR, barG, barB, 0.35) + row.pctBar:Show() + + row.cols.name:SetText(r.name) + row.cols.name:SetTextColor(cr, cg, cb) + + row.cols.calls:SetText(CommaNumber(r.calls)) + row.cols.calls:SetTextColor(0.8, 0.8, 0.8) + + row.cols.total:SetText(FormatMs(r.total)) + row.cols.total:SetTextColor(0.8, 0.8, 0.8) + + -- Self is the ranked figure, so it reads brighter than Total. + row.cols.selfMs:SetText(FormatMs(r.selfMs or 0)) + row.cols.selfMs:SetTextColor(0.95, 0.95, 0.8) + + row.cols.avg:SetText(FormatUs(r.avg)) + row.cols.avg:SetTextColor(0.7, 0.7, 0.7) + + row.cols.max:SetText(FormatUs(r.max)) + row.cols.max:SetTextColor(0.7, 0.7, 0.7) + + row.cols.peak:SetText(FormatPeak(r.peak)) + -- Tint Peak/tick yellow when it's high (>=20 calls in one frame) + -- — that's the cascade signal we're hunting. + if r.peak >= 20 then + row.cols.peak:SetTextColor(1, 0.85, 0.4) + else + row.cols.peak:SetTextColor(0.7, 0.7, 0.7) + end + + row.cols.mem:SetText(FormatBytes(r.mem)) + -- Tint Mem yellow when bytes/call >= 256, red when >= 1024 + if r.mem >= 1024 then + row.cols.mem:SetTextColor(1, 0.5, 0.5) + elseif r.mem >= 256 then + row.cols.mem:SetTextColor(1, 0.85, 0.4) + else + row.cols.mem:SetTextColor(0.7, 0.7, 0.7) + end + + row.cols.pct:SetText(format("%.1f%%", r.pct)) + row.cols.pct:SetTextColor(cr, cg, cb) + + row:Show() + else + row:Hide() + end + end + + -- Keep combat button text in sync + if profilerFrame.combatBtn then + if Profiler.combatAuto then + profilerFrame.combatBtn:SetText("|cff00ff00Combat|r") + else + profilerFrame.combatBtn:SetText("Combat") + end + end + + -- Keep split button text in sync + if profilerFrame.splitBtn then + if Profiler.splitByFrame then + profilerFrame.splitBtn:SetText("|cff00ff00Split|r") + else + profilerFrame.splitBtn:SetText("Split") + end + end + + -- Keep view button text in sync + if profilerFrame.viewBtn then + if Profiler.viewMode == "events" then + profilerFrame.viewBtn:SetText("|cff00ff00Events|r") + elseif Profiler.viewMode == "updates" then + profilerFrame.viewBtn:SetText("|cff00ff00OnUpdate|r") + else + profilerFrame.viewBtn:SetText("Functions") + end + end +end +Profiler.UpdateUI = UpdateUI -- resident combat/timer handlers refresh through this + + +function Profiler:CreateUI() + if profilerFrame then + profilerFrame:Show() + return + end + + -- ☠ This window is built from settings-panel widgets -- CreateInfoBanner in + -- particular, which parents the hook checkbox, so there is nothing sensible + -- to fall back to if it is missing. The profiler is opened deliberately, so + -- pulling the companion in here costs nothing anyone will notice and keeps + -- the panel whole. Same call the settings panel itself uses. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then + return + end + + -- Main frame + local f = CreateFrame("Frame", "DFProfilerFrame", UIParent, "BackdropTemplate") + -- Bottom slack has to clear TWO stacked things, not one: + -- 6 banner offset from the frame bottom + -- 34 the banner's minimum height + -- 6 gap + -- 24 the column legend that sits above it (TWO lines since Self landed) + -- 10 breathing room before the last data row + -- The old value was 30, which did not even clear the 28px strip that used to + -- live there — a full 30-row table clipped its last row behind it, and the + -- legend was underneath the strip entirely. + f:SetSize(FRAME_WIDTH, DATA_START_Y * -1 + MAX_ROWS * ROW_HEIGHT + 80) + f:SetPoint("CENTER", 0, 50) + -- Panel chrome, same as every other DF window. Was a bespoke plate from three + -- hand-mixed literals, which is the other half of why this window read as + -- foreign next to the settings frame. + DF.GUI:CreatePanelBackdrop(f) + f:SetFrameStrata("HIGH") + f:SetMovable(true) + f:EnableMouse(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", f.StartMoving) + f:SetScript("OnDragStop", f.StopMovingOrSizing) + f:SetClampedToScreen(true) + profilerFrame = f + Profiler.frame = f -- resident hook excludes the window; combat handlers check it + + -- Title. The "DF" used to be hardcoded bright green, which matched nothing + -- else in the addon; the theme accent is what every section header uses and + -- it follows the party/raid pole like the rest of the GUI. + local title = f:CreateFontString(nil, "OVERLAY", "DFFontNormalLarge") + title:SetPoint("TOPLEFT", 12, -10) + title:SetText("Profiler") + do + local tc = DF.GUI.GetThemeColor and DF.GUI.GetThemeColor() + if tc then title:SetTextColor(tc.r, tc.g, tc.b) end + end + + -- Close button. Was Blizzard's raw UI-Panel-MinimizeButton textures, which is + -- why this window's X had chrome no other DF surface has. + local closeBtn = DF.GUI:CreateCloseButton(f, { + onClick = function() f:Hide() end, + tooltip = "Close", + }) + closeBtn:SetPoint("TOPRIGHT", -6, -6) + + -- Status line + f.statusText = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + f.statusText:SetPoint("TOPLEFT", 12, -32) + f.statusText:SetJustifyH("LEFT") + f.statusText:SetText("|cff888888Ready|r Press Start to begin profiling") + + -- Buttons + local btnY = -46 + local btnH = 20 + local btnW = 68 + + f.toggleBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + DF.GUI:StyleButton(f.toggleBtn, { width = btnW, height = btnH, text = "Start" }) + f.toggleBtn:SetPoint("TOPLEFT", 10, btnY) + f.toggleBtn:SetScript("OnClick", function() + Profiler:Toggle() + UpdateUI() + UpdateColumnHeaders() + end) + + local resetBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + DF.GUI:StyleButton(resetBtn, { width = btnW, height = btnH, text = "Reset" }) + resetBtn:SetPoint("LEFT", f.toggleBtn, "RIGHT", 4, 0) + resetBtn:SetScript("OnClick", function() + Profiler:Reset() + UpdateUI() + end) + + local printBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + DF.GUI:StyleButton(printBtn, { width = 88, height = btnH, text = "Print to Chat" }) + printBtn:SetPoint("LEFT", resetBtn, "RIGHT", 4, 0) + printBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + printBtn:SetScript("OnClick", function(self, button) + if button == "RightButton" then + Profiler:PrintSummary() + else + Profiler:PrintResults() + end + end) + printBtn:SetScript("OnEnter", function(self) + DF.GUI:ShowTooltip(self, { + title = "Print to Chat", + lines = { + "Left-click: dump the current view (functions/events/onupdate)", + "Right-click: print Top 5 across all categories (summary)", + }, + }) + end) + printBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) + + -- Custom duration input box + local durationInput = CreateFrame("EditBox", nil, f, "BackdropTemplate") + durationInput:SetSize(36, btnH) + durationInput:SetPoint("LEFT", printBtn, "RIGHT", 12, 0) + DF.GUI:CreateElementBackdrop(durationInput, { + bgColor = { 0.1, 0.1, 0.1, 1 }, + borderColor = { 0.4, 0.4, 0.4, 1 }, + }) + durationInput:SetFontObject(DFFontHighlightSmall) + durationInput:SetJustifyH("CENTER") + durationInput:SetAutoFocus(false) + durationInput:SetNumeric(true) + durationInput:SetMaxLetters(4) + durationInput:SetText("30") + -- Allow clicking away to clear focus + durationInput:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + durationInput:SetScript("OnEnterPressed", function(self) + self:ClearFocus() + local dur = tonumber(self:GetText()) or 30 + if dur < 1 then dur = 1 end + Profiler:QuickProfile(dur) + UpdateUI() + UpdateColumnHeaders() + end) + + -- "s Run" button (triggers timed profile with input value) + local runBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + DF.GUI:StyleButton(runBtn, { width = 48, height = btnH, text = "s Run" }) + runBtn:SetPoint("LEFT", durationInput, "RIGHT", 2, 0) + runBtn:SetScript("OnClick", function() + durationInput:ClearFocus() + local dur = tonumber(durationInput:GetText()) or 30 + if dur < 1 then dur = 1 end + Profiler:QuickProfile(dur) + UpdateUI() + UpdateColumnHeaders() + end) + + -- Combat Auto toggle button + f.combatBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + -- text = "" so StyleButton creates AND registers its fontstring; the + -- label itself is set by the Update*BtnText below, which needs it to exist. + DF.GUI:StyleButton(f.combatBtn, { width = 78, height = btnH, text = "" }) + f.combatBtn:SetPoint("LEFT", runBtn, "RIGHT", 8, 0) + local function UpdateCombatBtnText() + if Profiler.combatAuto then + f.combatBtn:SetText("|cff00ff00Combat|r") + else + f.combatBtn:SetText("Combat") + end + end + f.combatBtn:SetScript("OnClick", function() + Profiler:SetCombatAuto(not Profiler.combatAuto) + UpdateCombatBtnText() + UpdateUI() + end) + f.combatBtn:SetScript("OnEnter", function(self) + DF.GUI:ShowTooltip(self, { + title = "Combat Auto-Profile", + lines = { Profiler.combatAuto + and { text = "ON: Profiling starts on combat, stops + prints on combat end.", + color = { 0, 1, 0 } } + or "OFF: Click to enable automatic combat profiling." }, + }) + end) + f.combatBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) + UpdateCombatBtnText() + + -- View cycle button (Functions / Events / OnUpdate). Top-right, left of Split. + f.viewBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + -- text = "" so StyleButton creates AND registers its fontstring; the + -- label itself is set by the Update*BtnText below, which needs it to exist. + DF.GUI:StyleButton(f.viewBtn, { width = 82, height = btnH, text = "" }) + f.viewBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -84, btnY) + local VIEW_LABELS = { + functions = "Functions", + events = "|cff00ff00Events|r", + updates = "|cff00ff00OnUpdate|r", + } + local VIEW_NEXT = { + functions = "events", + events = "updates", + updates = "functions", + } + local function UpdateViewBtnText() + f.viewBtn:SetText(VIEW_LABELS[Profiler.viewMode] or "Functions") + end + f.viewBtn:SetScript("OnClick", function() + Profiler.viewMode = VIEW_NEXT[Profiler.viewMode] or "functions" + UpdateViewBtnText() + UpdateUI() + UpdateColumnHeaders() + end) + f.viewBtn:SetScript("OnEnter", function(self) + DF.GUI:ShowTooltip(self, { + title = "View Mode", + lines = { + "Click to cycle: Functions → Events → OnUpdate.", + "Functions: time per DF method", + "Events: time per WoW event (UNIT_AURA, etc.)", + "OnUpdate: time per OnUpdate handler (every-frame ticks)", + }, + }) + end) + f.viewBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) + UpdateViewBtnText() + + -- Split by Frame Type toggle button + f.splitBtn = CreateFrame("Button", nil, f, "BackdropTemplate") + -- text = "" so StyleButton creates AND registers its fontstring; the + -- label itself is set by the Update*BtnText below, which needs it to exist. + DF.GUI:StyleButton(f.splitBtn, { width = 50, height = btnH, text = "" }) + f.splitBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -28, btnY) + local function UpdateSplitBtnText() + if Profiler.splitByFrame then + f.splitBtn:SetText("|cff00ff00Split|r") + else + f.splitBtn:SetText("Split") + end + end + f.splitBtn:SetScript("OnClick", function() + Profiler.splitByFrame = not Profiler.splitByFrame + UpdateSplitBtnText() + UpdateUI() + end) + f.splitBtn:SetScript("OnEnter", function(self) + DF.GUI:ShowTooltip(self, { + title = "Split by Frame Type", + lines = { Profiler.splitByFrame + and { text = "ON: Showing per-type breakdown (Party, Raid, HL-Party, HL-Raid).", + color = { 0, 1, 0 } } + or "OFF: Click to split results by frame type." }, + }) + end) + f.splitBtn:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) + UpdateSplitBtnText() + + -- ============================================================ + -- ONUPDATE TRACKING STRIP (bottom of the window, above the rows) + -- ============================================================ + -- What the toggle actually controls: a hooksecurefunc on the Frame + -- metatable's SetScript (see the top of this file), which registers every + -- OnUpdate handler a DF frame installs. That registry IS the OnUpdate tab — + -- with the hook off, the tab has no data source and is simply empty. + -- + -- ☠ It can only be installed at FILE-LOAD time: the hook has to be in place + -- before frames install their handlers, or everything set up during login is + -- invisible. Hence /rl, and hence the setting living in DandersFramesDB_v2 + -- (account SavedVariables) rather than a profile — it must be readable + -- before profiles initialise. + -- + -- ⚠ FORWARD-DECLARED. The checkbox's OnClick closure below calls this, and + -- the closure is built BEFORE the definition. Without this line the name + -- resolves as a global at closure-creation time, reads nil, and clicking the + -- box back to its current state throws "attempt to call a nil value" — the + -- same out-of-scope-local trap that left 8 dead branches in ColorPicker. + local UpdateHookBanner + + -- The real banner system, not a lookalike. This was a hand-built frame with + -- two hand-mixed colour literals approximating the "caution" tone; SetTone + -- gives the exact palette plus the matching icon, and cannot drift from the + -- banners on the settings pages. + -- + -- CreateInfoBanner self-measures its height and calls GUI:RelayoutHost. That + -- is a no-op here — RelayoutHost only does work for a widget inside a + -- settingsGroup, and walks the parent chain looking for a RefreshStates host + -- it will never find on a floating window — so the banner simply sizes + -- itself against the two anchors below. + local hookBanner = DF.GUI:CreateInfoBanner(f, { tone = "caution" }) + hookBanner:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 10, 6) + hookBanner:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 6) + f.hookBanner = hookBanner + + local hookCheckbox = CreateFrame("CheckButton", nil, hookBanner, "BackdropTemplate") + hookCheckbox:SetPoint("RIGHT", -6, 0) + DF.GUI:StyleCheckButton(hookCheckbox) + hookCheckbox:SetChecked(self.onUpdateHookEnabled) + hookCheckbox:SetScript("OnClick", function(cb) + if not DandersFramesDB_v2 then DandersFramesDB_v2 = {} end + local newState = cb:GetChecked() + DandersFramesDB_v2.profilerOnUpdateHook = newState + if newState == self.onUpdateHookEnabled then + -- Toggled back to what is already live — no reload needed. + UpdateHookBanner() + else + hookBanner:SetTone("caution") + hookBanner:SetText(newState + and "OnUpdate tracking will be ON after /rl." + or "OnUpdate tracking will be OFF after /rl.") + end + end) + hookCheckbox:SetScript("OnEnter", function(cb) + DF.GUI:ShowTooltip(cb, { + title = "Track OnUpdate handlers", + lines = { + "Hooks SetScript so the profiler can see every OnUpdate a DF frame installs. This is the data source for the OnUpdate tab — with it off, that tab is empty.", + "Applies at load only, so a /rl is required either way.", + }, + }) + end) + hookCheckbox:SetScript("OnLeave", function() DF.GUI:HideTooltip() end) + + local hookLabel = hookBanner:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + hookLabel:SetPoint("RIGHT", hookCheckbox, "LEFT", -2, 0) + hookLabel:SetText("Track OnUpdate") + + -- Keep the banner's body clear of the toggle. The factory anchors the body + -- TOPLEFT-of-icon and RIGHT-of-banner; re-issue BOTH points rather than just + -- the right one, so there is no question of a stale anchor surviving. + hookBanner.body:ClearAllPoints() + hookBanner.body:SetPoint("TOPLEFT", hookBanner.icon, "TOPRIGHT", 8, -5) + hookBanner.body:SetPoint("RIGHT", hookLabel, "LEFT", -10, 0) + + -- ☠ The strip stays VISIBLE in both states. It used to Hide() itself once + -- the hook was on — and the checkbox lives inside it, so the only way to + -- turn tracking back off was the /df debug profiler hook command. A toggle + -- you can reach in one direction only is not a toggle. + function UpdateHookBanner() + if self.onUpdateHookEnabled then + hookBanner:SetTone("info") + hookBanner:SetText("OnUpdate tracking is on.") + else + hookBanner:SetTone("caution") + hookBanner:SetText("OnUpdate tracking is off — the OnUpdate tab will be empty.") + end + hookCheckbox:SetChecked(DandersFramesDB_v2 and DandersFramesDB_v2.profilerOnUpdateHook or false) + hookBanner:Show() + end + UpdateHookBanner() + + -- Column headers + local xOffset = CONTENT_LEFT + 2 + for _, col in ipairs(COLUMNS) do + local hdr = CreateFrame("Button", nil, f) + hdr:SetHeight(18) + hdr:SetPoint("TOPLEFT", f, "TOPLEFT", xOffset, HEADER_Y) + hdr:SetWidth(col.width) + + local text = hdr:CreateFontString(nil, "OVERLAY", "DFFontNormalSmall") + text:SetAllPoints() + text:SetJustifyH(col.align) + text:SetTextColor(0.5, 0.75, 1.0) + + local label = col.label + if Profiler.sortColumn == col.key then + label = label .. (Profiler.sortDesc and " v" or " ^") + end + text:SetText(label) + headerTexts[col.key] = text + + -- Click to sort + local colKey = col.key + hdr:SetScript("OnClick", function() + if Profiler.sortColumn == colKey then + Profiler.sortDesc = not Profiler.sortDesc + else + Profiler.sortColumn = colKey + Profiler.sortDesc = true + end + UpdateColumnHeaders() + UpdateUI() + end) + hdr:SetScript("OnEnter", function() text:SetTextColor(0.8, 1.0, 1.0) end) + hdr:SetScript("OnLeave", function() text:SetTextColor(0.5, 0.75, 1.0) end) + + xOffset = xOffset + col.width + 4 + end + + -- Header divider + local divider = f:CreateTexture(nil, "ARTWORK") + divider:SetColorTexture(0.3, 0.3, 0.3, 0.6) + divider:SetHeight(1) + divider:SetPoint("TOPLEFT", f, "TOPLEFT", CONTENT_LEFT, HEADER_Y - 18) + divider:SetPoint("TOPRIGHT", f, "TOPRIGHT", CONTENT_RIGHT, HEADER_Y - 18) + + -- Data rows + for i = 1, MAX_ROWS do + local row = CreateRow(f, i) + row:SetPoint("TOP", f, "TOP", 0, DATA_START_Y - (i - 1) * ROW_HEIGHT) + dataRows[i] = row + end + + -- Column legend, stacked ABOVE the hook banner. + -- ☠ It used to be anchored to the frame's own bottom at y=8 — which is inside + -- the banner's rect. That was survivable only while the banner HID itself + -- whenever tracking was on; now that the banner is always visible (so the + -- toggle is reachable in both directions) the legend sat behind it + -- permanently, bleeding through as ghost text. Anchor to the banner instead + -- of to the frame, so it also tracks the banner if its text ever wraps. + local infoLabel = f:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + infoLabel:SetPoint("BOTTOMLEFT", f.hookBanner, "TOPLEFT", 2, 6) + infoLabel:SetTextColor(0.4, 0.4, 0.4) + infoLabel:SetJustifyH("LEFT") + -- Two lines. The first says what the numbers ARE, which matters because + -- Total and Self answer different questions and only one of them adds up. + infoLabel:SetText( + "Total = inclusive (counts nested profiled calls) | Self = excludes them — this is what % ranks, and % sums to 100\n" .. + "Peak/tk = max calls in one frame | Bytes = avg alloc per call | wrapper overhead subtracted; a row cheaper than it reads 0.00, not below zero") + + -- Live refresh via OnUpdate + f.elapsed = 0 + f:SetScript("OnUpdate", function(self, elapsed) + self.elapsed = self.elapsed + elapsed + if self.elapsed < 0.5 then return end + self.elapsed = 0 + UpdateUI() + end) + + UpdateUI() +end + +function Profiler:ToggleUI() + if profilerFrame and profilerFrame:IsShown() then + profilerFrame:Hide() + else + self:CreateUI() + end +end diff --git a/DandersFrames/GUI/IconLib.lua b/DandersFrames_Options/GUI/IconLib.lua similarity index 98% rename from DandersFrames/GUI/IconLib.lua rename to DandersFrames_Options/GUI/IconLib.lua index 2662c687..c25c6161 100644 --- a/DandersFrames/GUI/IconLib.lua +++ b/DandersFrames_Options/GUI/IconLib.lua @@ -1,4 +1,5 @@ -local addonName, DF = ... +-- ☠ Companion addon: bind the parent table from the global, never `...`. +local DF = DandersFrames -- Icon Library Preview -- Use /dficons to open the preview window diff --git a/generate_changelog.sh b/generate_changelog.sh index 6ca77968..d1d3e02e 100755 --- a/generate_changelog.sh +++ b/generate_changelog.sh @@ -7,7 +7,13 @@ set -euo pipefail CHANGELOG_FILE="CHANGELOG.md" # Core/ since the 2026-07 folder reorg. release.yml greps this same path for the # release channel and skips builds that only touch it — keep all three in step. +# +# Two outputs since the LoadOnDemand split: the resident file carries only the +# stamped BUILD_DATE/RELEASE_CHANNEL header; the ~180 KB changelog text goes to +# the companion, which is the only thing that ever reads it. release.yml's +# build-skip filter lists BOTH paths. OUTPUT_FILE="DandersFrames/Core/Changelog.lua" +OUTPUT_TEXT_FILE="DandersFrames_Options/Changelog.lua" if [ ! -f "$CHANGELOG_FILE" ]; then echo "Error: $CHANGELOG_FILE not found." @@ -166,14 +172,27 @@ else echo "TOC version already matches: ${VERSION}" fi -# Write Changelog.lua +# Write the resident header (stamps only -- the addon reads RELEASE_CHANNEL at +# load; the text below never loads unless the settings panel does) cat > "$OUTPUT_FILE" << LUAEOF local addonName, DF = ... DF.BUILD_DATE = "${BUILD_DATE}" DF.RELEASE_CHANNEL = "${RELEASE_CHANNEL}" +-- The changelog text itself lives in DandersFrames_Options/Changelog.lua: +-- it is a ~180 KB string only the settings panel ever reads, and holding it +-- resident cost every player that memory forever. Both files are GENERATED +-- by generate_changelog.sh -- edit CHANGELOG.md, not either output. +LUAEOF + +# Write the companion text. NOTE the DF binding: a companion file's vararg is +# its OWN addon table, so it must take the parent's from the global. +cat > "$OUTPUT_TEXT_FILE" << LUAEOF +-- GENERATED by generate_changelog.sh from CHANGELOG.md -- do not edit. +-- ☠ Companion addon: bind the parent's table from the global, never \`...\`. +local DF = DandersFrames DF.CHANGELOG_TEXT = [===[ ${CHANGELOG_CONTENT} ]===] LUAEOF -echo "Generated ${OUTPUT_FILE}: channel=${RELEASE_CHANNEL} date=${BUILD_DATE}" +echo "Generated ${OUTPUT_FILE} + ${OUTPUT_TEXT_FILE}: channel=${RELEASE_CHANNEL} date=${BUILD_DATE}" From cf790865388795a7dcea8995075256f2d7075f68 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 22:16:05 +0100 Subject: [PATCH 28/34] Perf hygiene from the audit sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding against the code first -- several were already handled, and the commit only carries what was actually wrong. - Frames/Border.lua: the shared border-animation driver never stopped. It is created on first animated border and its OnUpdate then ran every frame for the rest of the session, paying a MemTestDisabled call and an empty pairs() loop long after the last animation ended. Registering shows it, and unregistering hides it when the registry empties. - Frames/ReducedMaxHealth.lua: UNIT_MAX_HEALTH_MODIFIERS_CHANGED is a per-unit event that storms when an affix or raid debuff lands, and the handler walked every party AND raid frame to find the one unit -- quadratic across a storm, in M+ and raid, which is exactly where it fires. Single-unit lookups now go through DF.unitFrameMap and fall through to the iterators when the map has no entry, so pinned frames (deliberately excluded from that map) behave as before. - Features/HealthFade.lua: the curve cache key was built by concatenating three numbers into a string on EVERY health update of every frame, purely to look up a hit. Now nested by the three numbers: three hash lookups, allocation only on a real miss. wipe() in the invalidator still clears it. - Frames/StatusIcons.lua: pcall(closure) -> pcall(fn, arg) in the 1s phased ticker, which allocated a closure per unit per tick in the open world. The file already used the cheaper form a few lines above. - Frames/Core.lua: DF.CHILD_ATTR, precomputed "child1".."child40". Applied to the three per-event header scans the sweep flagged -- the pinned-frame lookup in Headers.lua and its duplicates in Range and Highlights -- which between them rebuilt up to 80 of those strings per unit event. The ~80 cold sites still concatenate; that reads better and costs nothing there. - Guarded three debugstack() sites that ran with logging OFF: Headers.lua's UpdateHeaderVisibility trace (every roster and zone change) was fully unguarded, and Position.lua's raid-anchor write log and container-reposition log tested `DF.Debug` -- which only asks whether the function exists, and it always does -- so ShortCaller built a stack string every time. Now DebugActive. ⚠ The audit listed eight debugstack sites; five were already correctly behind DF:DebugActive("FLATRAID"). Only these three were real. Not taken: the pinned-frame unit map that would remove those scans entirely. It is the bigger win but the invalidation (children re-slot on roster change) needs in-game validation, so it is on the Perfy list with the other structural items rather than done blind. --- DandersFrames/Features/HealthFade.lua | 25 ++++++++++++++++++++--- DandersFrames/Features/Highlights.lua | 3 ++- DandersFrames/Features/Range.lua | 3 ++- DandersFrames/Frames/Border.lua | 9 +++++++- DandersFrames/Frames/Core.lua | 14 +++++++++++++ DandersFrames/Frames/Headers.lua | 22 ++++++++++++++++---- DandersFrames/Frames/Position.lua | 9 ++++++-- DandersFrames/Frames/ReducedMaxHealth.lua | 15 ++++++++++++++ DandersFrames/Frames/StatusIcons.lua | 7 ++++--- 9 files changed, 92 insertions(+), 15 deletions(-) diff --git a/DandersFrames/Features/HealthFade.lua b/DandersFrames/Features/HealthFade.lua index 80c3f82a..d2f8c371 100644 --- a/DandersFrames/Features/HealthFade.lua +++ b/DandersFrames/Features/HealthFade.lua @@ -24,11 +24,20 @@ local issecretvalue = issecretvalue -- Typically only 1-2 unique curves at any time. -- ============================================================ +-- ☠ Nested by the three numbers rather than one concatenated string key. This +-- is reached from SetHealthBarValue on EVERY health update of every frame, and +-- building "threshold_below_above" cost two number->string conversions plus two +-- concatenations per tick purely to look up a cache HIT. Nesting is three hash +-- lookups and allocates only on a genuine miss. local healthFadeCurveCache = {} local function BuildHealthFadeCurve(threshold, belowAlpha, aboveAlpha) - local key = threshold .. "_" .. belowAlpha .. "_" .. aboveAlpha - if healthFadeCurveCache[key] then return healthFadeCurveCache[key] end + local byThreshold = healthFadeCurveCache[threshold] + if byThreshold then + local byBelow = byThreshold[belowAlpha] + local hit = byBelow and byBelow[aboveAlpha] + if hit then return hit end + end local curve = C_CurveUtil.CreateColorCurve() curve:SetType(Enum.LuaCurveType.Linear) @@ -43,7 +52,17 @@ local function BuildHealthFadeCurve(threshold, belowAlpha, aboveAlpha) if pos < 0.999 then curve:AddPoint(pos + 0.001, aboveColor) end curve:AddPoint(1, aboveColor) - healthFadeCurveCache[key] = curve + byThreshold = healthFadeCurveCache[threshold] + if not byThreshold then + byThreshold = {} + healthFadeCurveCache[threshold] = byThreshold + end + local byBelow = byThreshold[belowAlpha] + if not byBelow then + byBelow = {} + byThreshold[belowAlpha] = byBelow + end + byBelow[aboveAlpha] = curve return curve end diff --git a/DandersFrames/Features/Highlights.lua b/DandersFrames/Features/Highlights.lua index 4ea67819..a6925a35 100644 --- a/DandersFrames/Features/Highlights.lua +++ b/DandersFrames/Features/Highlights.lua @@ -1004,7 +1004,8 @@ highlightEventFrame:SetScript("OnEvent", function(self, event, ...) if header and header:IsShown() then local maxChildren = IsInRaid() and 40 or 5 for i = 1, maxChildren do - local child = header:GetAttribute("child" .. i) + -- Precomputed attribute name: per threat/target event. + local child = header:GetAttribute(DF.CHILD_ATTR[i]) if child and child:IsShown() and child.unit == unit then DF:UpdateHighlights(child) end diff --git a/DandersFrames/Features/Range.lua b/DandersFrames/Features/Range.lua index 39b3f12b..e31f11ee 100644 --- a/DandersFrames/Features/Range.lua +++ b/DandersFrames/Features/Range.lua @@ -863,7 +863,8 @@ function rangeSubscriber:OnUnitInRange(event, unit) if header and header:IsShown() then local maxChildren = DF.PinnedFrames.currentMode == "raid" and 40 or 5 for i = 1, maxChildren do - local child = header:GetAttribute("child" .. i) + -- Precomputed attribute name: per UNIT_IN_RANGE_UPDATE. + local child = header:GetAttribute(DF.CHILD_ATTR[i]) if child and child:IsShown() and child.unit == unit then DF:UpdateRange(child) end diff --git a/DandersFrames/Frames/Border.lua b/DandersFrames/Frames/Border.lua index 029df03e..185aba5d 100644 --- a/DandersFrames/Frames/Border.lua +++ b/DandersFrames/Frames/Border.lua @@ -733,7 +733,7 @@ end -- Register/replace this border's per-frame tick. initialElapsed seeds the -- accumulator (DF_DASH resumes its march across restarts; others start at 0). local function registerAnimTick(border, fn, initialElapsed) - ensureSharedAnimDriver() + ensureSharedAnimDriver():Show() local e = animRegistry[border] if not e then e = {}; animRegistry[border] = e end e.fn = fn @@ -741,6 +741,13 @@ local function registerAnimTick(border, fn, initialElapsed) end local function unregisterAnimTick(border) animRegistry[border] = nil + -- ☠ Hide the driver once the last animated border goes. An OnUpdate on a + -- shown frame runs every frame forever; emptying the registry alone left + -- it paying a MemTestDisabled call and an empty pairs() loop for the rest + -- of the session once any border had animated even once. + if sharedAnimDriver and next(animRegistry) == nil then + sharedAnimDriver:Hide() + end end -- Reset all four edges to fully opaque. Called from StopAnimation so the diff --git a/DandersFrames/Frames/Core.lua b/DandersFrames/Frames/Core.lua index 4b41cdef..f87354ea 100644 --- a/DandersFrames/Frames/Core.lua +++ b/DandersFrames/Frames/Core.lua @@ -773,3 +773,17 @@ function DF:RefreshIconPreviews() if not list then return end for i = 1, #list do list[i]() end end + +-- Precomputed secure-header child attribute names: CHILD_ATTR[i] == "child"..i. +-- +-- Header iteration is written as `header:GetAttribute("child" .. i)`, which is +-- fine once but not inside per-unit event handlers -- the pinned-frame lookup +-- alone rebuilds up to 80 of these strings on every UNIT_HEALTH, UNIT_POWER and +-- UNIT_AURA. Indexing a ready table is a hash lookup instead of a concat. +-- +-- Only the hot scans use this. The ~80 cold sites (setup, options, one-off +-- sweeps) still concatenate, which reads better and costs nothing there. +DF.CHILD_ATTR = {} +for i = 1, 40 do + DF.CHILD_ATTR[i] = "child" .. i +end diff --git a/DandersFrames/Frames/Headers.lua b/DandersFrames/Frames/Headers.lua index 393e0f33..1916d75c 100755 --- a/DandersFrames/Frames/Headers.lua +++ b/DandersFrames/Frames/Headers.lua @@ -4454,9 +4454,14 @@ function DF:UpdateHeaderVisibility(skipRaidReposition) local inRaid = IsInRaid() and not inArena -- Raid but NOT arena local inParty = IsInGroup() and not IsInRaid() - DF:Debug("VISIBILITY", "UpdateHeaderVisibility: content=%s inRaid=%s inParty=%s skipRaidRepos=%s caller=%s", - tostring(contentType), tostring(inRaid), tostring(inParty), tostring(skipRaidReposition), - debugstack(2, 1, 0) or "?") + -- ☠ Guarded: debugstack() builds a stack string whether or not logging is + -- on, and this runs on every roster and zone change. The FLATRAID traces + -- elsewhere in this file already do it this way. + if DF:DebugActive("VISIBILITY") then + DF:Debug("VISIBILITY", "UpdateHeaderVisibility: content=%s inRaid=%s inParty=%s skipRaidRepos=%s caller=%s", + tostring(contentType), tostring(inRaid), tostring(inParty), tostring(skipRaidReposition), + debugstack(2, 1, 0) or "?") + end -- (Removed) an IsInInstance() call whose BOTH return values were unused — the -- "ARENA DEBUG" trace it fed is long gone, so the call did nothing but cost an @@ -7671,6 +7676,15 @@ end DF.IteratePinnedFrames = IteratePinnedFrames -- Helper to find pinned frame for a specific unit (player-mode or boss-mode set) +-- +-- ⚠ HOT. Called from the UNIT_HEALTH / UNIT_POWER / UNIT_NAME_UPDATE / +-- UNIT_CONNECTION / UNIT_AURA handlers below, i.e. per unit event. It early- +-- outs cheaply when pinned frames are off or hidden, so only pinned users pay +-- the scan -- but for them it is up to 2x40 secure attribute reads per event. +-- CHILD_ATTR keeps the string cost out of it; replacing the scan itself with a +-- maintained pinned-unit map is the real fix and is on the Perfy list, because +-- the invalidation (frames re-slot on roster change) needs in-game validation. +local CHILD_ATTR = DF.CHILD_ATTR local function FindPinnedFrameForUnit(unit) if not DF.PinnedFrames or not DF.PinnedFrames.initialized then return nil @@ -7681,7 +7695,7 @@ local function FindPinnedFrameForUnit(unit) local header = DF.PinnedFrames.headers[setIndex] if header and header:IsShown() then for i = 1, 40 do - local child = header:GetAttribute("child" .. i) + local child = header:GetAttribute(CHILD_ATTR[i]) if child and child:IsVisible() and child.unit == unit then return child end diff --git a/DandersFrames/Frames/Position.lua b/DandersFrames/Frames/Position.lua index 0de06b57..c30165ed 100644 --- a/DandersFrames/Frames/Position.lua +++ b/DandersFrames/Frames/Position.lua @@ -32,7 +32,10 @@ end -- Log a write to db.raidAnchorX/Y. Call BEFORE the assignment so -- we can capture the old value, then perform the assignment. function DF:LogRaidAnchorWrite(reason, newX, newY) - if not DF.Debug then return end + -- ☠ DebugActive, not `DF.Debug` -- that only asked whether the function + -- exists, which it always does, so ShortCaller's debugstack() built a + -- stack string on every raid-anchor write with logging off. + if not DF:DebugActive("RAIDPOS") then return end local db = DF:GetRaidDB() local oldX, oldY = db and db.raidAnchorX or 0, db and db.raidAnchorY or 0 DF:Debug("RAIDPOS", "raidAnchor WRITE [%s] @ %s : (%.1f,%.1f) -> (%.1f,%.1f)", @@ -2312,7 +2315,9 @@ function DF:UpdateRaidContainerPosition() end local cx, cy = x + dx, y + dy - if DF.Debug then + -- ☠ DebugActive, not `DF.Debug`: ShortCaller runs debugstack(), and this + -- fires on every raid container reposition. + if DF:DebugActive("RAIDPOS") then if dx ~= 0 or dy ~= 0 then DF:Debug("RAIDPOS", "UpdateRaidContainerPosition @ %s : applying (%.1f,%.1f) +comp(%.1f,%.1f) -> (%.1f,%.1f) scale=%.3f combat=%s", ShortCaller(3), x, y, dx, dy, cx, cy, scale, tostring(InCombatLockdown())) diff --git a/DandersFrames/Frames/ReducedMaxHealth.lua b/DandersFrames/Frames/ReducedMaxHealth.lua index 30a4eb82..9236e946 100644 --- a/DandersFrames/Frames/ReducedMaxHealth.lua +++ b/DandersFrames/Frames/ReducedMaxHealth.lua @@ -184,6 +184,21 @@ function DF:UpdateAllVisibleReducedMaxHealth(unit) end end + -- ☠ Single-unit fast path. UNIT_MAX_HEALTH_MODIFIERS_CHANGED is PER-UNIT and + -- storms when an affix or raid-wide debuff hits the group; the iterators + -- below walk every party AND raid frame to find the one that matches, so a + -- storm is O(roster^2). The roster map exists for exactly this lookup. + -- Falls through when the map has no entry (pinned frames are deliberately + -- excluded from it), so behaviour is unchanged -- only the common case is + -- faster. + if unit and DF.unitFrameMap then + local mapped = DF.unitFrameMap[unit] + if mapped then + updateFrame(mapped) + return + end + end + if DF.IteratePartyFrames then DF:IteratePartyFrames(updateFrame) end diff --git a/DandersFrames/Frames/StatusIcons.lua b/DandersFrames/Frames/StatusIcons.lua index c766d0d0..d3d9ecca 100644 --- a/DandersFrames/Frames/StatusIcons.lua +++ b/DandersFrames/Frames/StatusIcons.lua @@ -622,9 +622,10 @@ local function PhasedTimerUpdate() else -- Open world: use distance gating for UnitPhaseReason reliability -- (UnitPhaseReason only works within ~250 yards) - local ok, distSq, valid = pcall(function() - return UnitDistanceSquared(unit) - end) + -- pcall(fn, arg), not pcall(closure): this runs per unit on a 1s + -- ticker in the open world, and the closure form allocated one each + -- time. Same form as the UnitInOtherParty call earlier in this file. + local ok, distSq, valid = pcall(UnitDistanceSquared, unit) if ok and valid and canaccessvalue(distSq) then local inrange = distSq < 62500 -- 250*250 if inrange ~= phasedRange[unit] then From 4604894b283ba00cc2c4be20cd03a7c3f53e8b8b Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 22:18:38 +0100 Subject: [PATCH 29/34] .pkgmeta: strip TRANSLATING.md and .gitnexus from the zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simulated the packager's ignore pass against the tracked file list rather than guessing: two repo-root entries were reaching the built zip that plainly should not. TRANSLATING.md is contributor documentation, alongside README/CHANGELOG which were already stripped; .gitnexus is editor tooling metadata (wow-api.json and friends) that nothing reads at runtime. With these added, exactly two entries survive the ignore list -- DandersFrames and DandersFrames_Options -- which is what the zip should contain. Also checked the thing that looked most likely to break in the container layout: release.yml's locale validation globs .release/DandersFrames/Locales/ *.lua, and after move-folders lifts DandersFrames/DandersFrames up to .release/DandersFrames, Locales/ sits directly inside it. That path still resolves. ⚠ Still not a real build. This verifies the ignore list and the path assumptions, not the packager's move-folders implementation. The tag test (push a throwaway tag, inspect the zip's top level for two sibling folders each with its own .toc, delete the tag) is still required before any real release. --- .pkgmeta | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.pkgmeta b/.pkgmeta index bc67fed2..56d6821c 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -21,10 +21,13 @@ move-folders: ignore: - CHANGELOG.md - README.md + - TRANSLATING.md - generate_changelog.sh - Tools - .github - .gitignore + # Editor/tooling metadata (wow-api.json and friends). Nothing reads it at runtime. + - .gitnexus # Contributor documentation. Tracked in git so it travels with pull requests, # stripped here so it never reaches the packaged addon users download. - docs From 614e08216716a78e460f8f555749af84c265432a Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 31 Jul 2026 22:31:57 +0100 Subject: [PATCH 30/34] Drop third-party addon names from .pkgmeta House rule: no other addons named in PR-facing material. The move-folders comment cited a specific addon as precedent; the reasoning it was carrying -- why the key looks like it collides with its parent and does not -- is what mattered, so that stays. --- .pkgmeta | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.pkgmeta b/.pkgmeta index 56d6821c..1a721419 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -9,8 +9,10 @@ enable-nolib-creation: no # package name as the root; values are relative to the package root. So # "DandersFrames/DandersFrames: DandersFrames" reads as "the DandersFrames # folder inside the package dir becomes the top-level DandersFrames" -- it -# looks like it collides with its own parent and does not. This is the same -# shape ElvUI ships (ElvUI/ElvUI: ElvUI, ElvUI/ElvUI_Options: ElvUI_Options). +# looks like it collides with its own parent and does not. This is the +# documented shape for a container repo that ships more than one folder; +# verified against a working example before writing it, after talking myself +# out of it once on the assumption that it would collide. # # Everything at the repo root that is not an addon folder is stripped by the # ignore list below, so the zip contains only the two addon folders. From 803c7e4e2b5a6f06d62c987bda28204002250802 Mon Sep 17 00:00:00 2001 From: Krathe Date: Sat, 1 Aug 2026 10:24:12 +0100 Subject: [PATCH 31/34] generate_changelog.sh: sync the companion TOC version too The packager does not rewrite "## Version:" -- its toc_version handling is the Interface (game) version, not that field -- so whatever is committed is what WoW shows in the addon list, both in the zip and locally through a dev junction. The sync step only ever touched the main TOC, which was fine when there was one; DandersFrames_Options would have sat at whatever it was hand-written as and drifted a version further behind on every release. Both TOCs now sync in a loop, and a missing TOC is a hard error rather than a silent skip -- if the layout changes again, this script should stop rather than quietly ship a stale version. Verified by setting the companion to v0.0.0-STALE and running the generator: it reported the main TOC already matching and corrected the companion. Found while doing the .pkgmeta build test that had been outstanding. That test now passes: the packager was run locally with uploads disabled (-d -e -l -z), which exercises the real move-folders implementation, and it produced exactly "Moving DandersFrames/DandersFrames to DandersFrames" and "Moving DandersFrames/DandersFrames_Options to DandersFrames_Options". The built package has the two folders as siblings at the top level, each with its own .toc, all 90 + 36 TOC entries present, the release.yml locale glob resolving to 11 files, and no repo-root files leaking in. Not done via a pushed tag on purpose: the Package and Release step passes CF_API_KEY, so a tag would have uploaded to CurseForge, cut a GitHub release and pinged the dev Discord. workflow_dispatch does not help either -- the job requires a main or tag ref. --- generate_changelog.sh | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/generate_changelog.sh b/generate_changelog.sh index d1d3e02e..a3d99a95 100755 --- a/generate_changelog.sh +++ b/generate_changelog.sh @@ -163,14 +163,26 @@ fi echo "$CHANGELOG_CONTENT" > "$CHANGELOG_FILE" # Sync TOC version if it doesn't already match (avoids double-bump when manually updated) -TOC_FILE="DandersFrames/DandersFrames.toc" -CURRENT_TOC_VERSION=$(grep '^## Version:' "$TOC_FILE" | sed 's/^## Version: //') -if [ "$CURRENT_TOC_VERSION" != "$VERSION" ]; then - sed -i "s/^## Version: .*/## Version: ${VERSION}/" "$TOC_FILE" - echo "Updated TOC version: ${CURRENT_TOC_VERSION} -> ${VERSION}" -else - echo "TOC version already matches: ${VERSION}" -fi +# +# ☠ BOTH TOCs. The packager does not rewrite "## Version:" -- its toc_version +# handling is the Interface (game) version, not this field -- so whatever is +# committed here is what WoW shows in the addon list, in the zip and locally +# through a dev junction. Syncing only the main TOC left the companion pinned +# at whatever it was hand-written as, drifting one version further behind on +# every release. +for TOC_FILE in "DandersFrames/DandersFrames.toc" "DandersFrames_Options/DandersFrames_Options.toc"; do + if [ ! -f "$TOC_FILE" ]; then + echo "Error: $TOC_FILE not found — the addon layout changed without this script." >&2 + exit 1 + fi + CURRENT_TOC_VERSION=$(grep '^## Version:' "$TOC_FILE" | sed 's/^## Version: //') + if [ "$CURRENT_TOC_VERSION" != "$VERSION" ]; then + sed -i "s/^## Version: .*/## Version: ${VERSION}/" "$TOC_FILE" + echo "Updated ${TOC_FILE}: ${CURRENT_TOC_VERSION} -> ${VERSION}" + else + echo "${TOC_FILE} already matches: ${VERSION}" + fi +done # Write the resident header (stamps only -- the addon reads RELEASE_CHANNEL at # load; the text below never loads unless the settings panel does) From 1c8f3ebbdc9664582214f175781eed5fec834e04 Mon Sep 17 00:00:00 2001 From: Krathe Date: Sat, 1 Aug 2026 10:43:52 +0100 Subject: [PATCH 32/34] Fix review findings from PR #222 Danders' review found two runtime defects, a policy leak, a dead command and a wrong claim I had repeated 30 times. All verified against the code before acting on them. BLOCKING - Two unguarded CC:RefreshClickCastingUI calls in ClickCasting/Profiles.lua, inside CheckLoadoutProfileSwitch, which fires from TRAIT_CONFIG_UPDATED and PLAYER_ENTERING_WORLD. Anyone with a click-cast profile assigned to a talent loadout who had never opened /df got a nil-call on login and on every talent swap. Not just an error: the throw aborted before ApplyBindings -- the reapply that exists to pick up talent spell-overrides -- and before the profile-switch confirmation. Both now use CC:RefreshUIIfLoaded, which already existed six lines from the handler that reaches them. - CURSOR_LIFT_X/Y were unbound in the companion. The declaration went resident with ShowTooltip while ShowGameTooltip stayed behind, so Sections.lua read two nil globals. Silent, because they are optional SetOwner args -- spell tooltips on settings rows lost their 8px lift while every other DF tooltip kept it. Published on _priv and aliased, so the pair cannot drift. Both are `self:` call shapes. lod_gate_check.py documented that exact blind spot and I did not do the manual sweep the limitation demands, so it now has a self: arm: in a resident file, self:X( where X is payload-owned is reported as that namespace. Heuristic, and labelled as one -- the false positive is a name collision, the false negative was a Lua error on every talent swap. Negative-tested by reverting one fix and watching it report. CORRECTION, MINE, IN 30 FILES The companion binding comment said the parent "publishes its own table as a global via ## AllowAddOnTableAccess". It does not: the global comes from _G[addonName] = DF at Core.lua:9. AllowAddOnTableAccess governs access to an addon's PRIVATE table, is unrelated to the global name, and was in the parent TOC before this branch. Harmless today, nasty later -- someone deleting Core.lua:9 as "redundant, the TOC handles it" would nil DF in every companion file at once. Corrected everywhere, including the string the changelog generator emits. ALSO - /df attached was dead. The scanner moved to the companion and registers no slash of its own, so it never reached the /df debug load-and-retry. Now loads, same as exportaudit two branches down. - GetImportInfo loaded the companion for ANY payload. The category tables are only read in the isFullExport branch; the guard moved inside it, so the full-profile Wago path no longer pays for something it does not use. - The conflict popup force-loaded the companion before checking ignoreConflictWarning -- a flag that is resident-readable and is the popup's own first line. A user who ticked "don't warn me again" pulled the whole settings addon at every login to reach a function that immediately returns. Flag hoisted into the resident guard at both sites. DOCS - README: the layout diagram omitted the companion; the junction-removal step said "delete any real AddOns/DandersFrames folder", which for an existing dev is a JUNCTION -- rm -rf follows it and eats the working tree. Now shows cmd //c rmdir with an explanation, adds _beta_ to the install list, states the LoD rule as a table, and is honest that docs/reorg-tools is not in the repo rather than citing files a reader cannot find. - The companion TOC claimed three load-bearing orderings. Two were not real: Sections/Controls/Panel alias only the resident toolkit, never each other, and the Pages parts are a runtime tail-call chain. The one genuine cross-addon constraint -- Pages/AutoProfiles aliasing the resident engine's _priv -- was undocumented. Corrected, since a reader trusting the wrong constraints and missing the real one is worse than no comment. - Companion TOC header still quoted the superseded 27.5% / 55,000 figures. NOT CHANGED, and why: the review called the committed companion Changelog.lua "stale (1 section vs CHANGELOG.md's 58)". It is not -- generate_changelog.sh trims to the first section on a minor/major bump, and the local run confirms "Minor/major bump detected (v4.9.0 -> v5.0.0-alpha.13): trimmed changelog". Regenerated anyway so the committed file matches a current run. --- DandersFrames/ClickCasting/Frames.lua | 15 ++-- DandersFrames/ClickCasting/Profiles.lua | 13 ++-- DandersFrames/Core.lua | 6 +- DandersFrames/Core/Changelog.lua | 2 +- DandersFrames/Core/Profile.lua | 12 ++- DandersFrames/GUI/Widgets.lua | 8 ++ .../AuraDesigner/UI/Cards.lua | 9 ++- .../AuraDesigner/UI/Editor.lua | 9 ++- .../AuraDesigner/UI/Groups.lua | 9 ++- .../AuraDesigner/UI/Indicators.lua | 9 ++- .../AuraDesigner/UI/Options.lua | 9 ++- DandersFrames_Options/Changelog.lua | 3 +- .../ClickCasting/UI/BindingEditor.lua | 9 ++- .../ClickCasting/UI/Dialogs.lua | 9 ++- .../ClickCasting/UI/Main.lua | 9 ++- .../ClickCasting/UI/ProfilesPanel.lua | 9 ++- .../Core/ExportCategories.lua | 7 +- .../DandersFrames_Options.toc | 25 ++++-- DandersFrames_Options/Debug/AtlasBrowser.lua | 9 ++- .../Debug/AttachmentScan.lua | 9 ++- DandersFrames_Options/Debug/AuraExplorer.lua | 9 ++- DandersFrames_Options/Debug/MemoryTest.lua | 9 ++- DandersFrames_Options/Debug/ProfilerUI.lua | 7 +- DandersFrames_Options/Features/Search.lua | 9 ++- .../FilterRegistry/UI/Options.lua | 9 ++- .../FilterRegistry/UI/SpellPicker.lua | 9 ++- DandersFrames_Options/GUI/ColorPicker.lua | 9 ++- DandersFrames_Options/GUI/Controls.lua | 9 ++- DandersFrames_Options/GUI/IconLib.lua | 7 +- DandersFrames_Options/GUI/Pages/Auras.lua | 9 ++- DandersFrames_Options/GUI/Pages/Frames.lua | 9 ++- .../GUI/Pages/Indicators.lua | 9 ++- DandersFrames_Options/GUI/Pages/Modules.lua | 9 ++- .../GUI/Pages/NicknamesPage.lua | 9 ++- .../GUI/Pages/NicknamesPicker.lua | 9 ++- DandersFrames_Options/GUI/Pages/Options.lua | 9 ++- DandersFrames_Options/GUI/Panel.lua | 9 ++- DandersFrames_Options/GUI/Sections.lua | 11 ++- .../TestMode/TestFramePool.lua | 9 ++- DandersFrames_Options/TestMode/TestMode.lua | 9 ++- .../TextDesigner/UI/Options.lua | 9 ++- README.md | 76 +++++++++++++++---- generate_changelog.sh | 3 +- 43 files changed, 323 insertions(+), 133 deletions(-) diff --git a/DandersFrames/ClickCasting/Frames.lua b/DandersFrames/ClickCasting/Frames.lua index d84f1adb..544e17fb 100755 --- a/DandersFrames/ClickCasting/Frames.lua +++ b/DandersFrames/ClickCasting/Frames.lua @@ -348,11 +348,16 @@ function CC:InitializeSecureFrames() -- Delay slightly to ensure UI is ready C_Timer.After(1.5, function() if CC.db.enabled and CC.hasConflictingAddons then - -- ☠ The popup lives in the load-on-demand companion; this fires at - -- login before anyone has opened the panel. It is a modal warning - -- the user must act on (Clique/Clicked conflict), so load the - -- companion for it rather than silently skip -- this only costs - -- users who actually have a conflicting addon installed. + -- ☠ Read the opt-out FIRST. The popup lives in the companion and + -- this fires at login, so loading it here is the cost -- but + -- ShowClickCastConflictPopup's own first line is this same check + -- and returns immediately. Without this, a user who ticked "don't + -- warn me again" pulled the entire settings addon in at every + -- login to reach a function that does nothing. The flag lives in + -- DandersFramesClickCastingDB and is fully readable resident. + if CC.db.ignoreConflictWarning then return end + -- It is a modal the user must act on, so load rather than skip. + -- Only users with a conflicting addon installed ever pay. if not CC.ShowClickCastConflictPopup then if not (DF.EnsureOptionsLoaded and DF:EnsureOptionsLoaded()) then return end end diff --git a/DandersFrames/ClickCasting/Profiles.lua b/DandersFrames/ClickCasting/Profiles.lua index cf678201..d167df15 100644 --- a/DandersFrames/ClickCasting/Profiles.lua +++ b/DandersFrames/ClickCasting/Profiles.lua @@ -353,9 +353,12 @@ function CC:SetActiveProfile(profileName) if self.hasConflictingAddons and self.conflictingAddons then C_Timer.After(0.1, function() if CC.db.enabled and CC.hasConflictingAddons then - -- ☠ Popup lives in the companion; profile switches are - -- reachable without the panel (slash, permanent movers, - -- loadout auto-switch). Modal warning -> load, don't skip. + -- ☠ Opt-out first — the popup's own first line is this check, + -- so loading the companion to reach it would be pure waste. + if CC.db.ignoreConflictWarning then return end + -- Popup lives in the companion; profile switches are reachable + -- without the panel (slash, permanent movers, loadout + -- auto-switch). Modal warning -> load, don't skip. if not CC.ShowClickCastConflictPopup then if not (DF.EnsureOptionsLoaded and DF:EnsureOptionsLoaded()) then return end end @@ -542,7 +545,7 @@ function CC:CheckLoadoutProfileSwitch() -- Profile is assigned (either to this specific loadout or as spec default) - switch to it if self:SetActiveProfile(assignedProfile) then self:ApplyBindings() - self:RefreshClickCastingUI() + self:RefreshUIIfLoaded() local source = isSpecific and "loadout: " .. loadoutName or "spec default" DF:Say("Switched to profile: " .. assignedProfile .. " (" .. source .. ")") end @@ -577,7 +580,7 @@ function CC:CheckLoadoutProfileSwitch() -- Switch to it if self:SetActiveProfile(newProfileName) then self:ApplyBindings() - self:RefreshClickCastingUI() + self:RefreshUIIfLoaded() end -- Show notification diff --git a/DandersFrames/Core.lua b/DandersFrames/Core.lua index 0fbca5e7..f486bec2 100644 --- a/DandersFrames/Core.lua +++ b/DandersFrames/Core.lua @@ -5500,7 +5500,11 @@ DF._MainEventDispatcher = function(self, event, arg1) DF:Err("Header info not available") end elseif msg == "attached" then - -- List other addons anchored/parented to DF unit frames + -- List other addons anchored/parented to DF unit frames. + -- Scanner lives in the companion and registers no slash of its + -- own, so it never reaches the /df debug load-and-retry — this + -- branch has to load it, same as exportaudit two branches down. + if DF.EnsureOptionsLoaded and not DF:EnsureOptionsLoaded() then return end if DF.ScanFrameAttachments then DF:ScanFrameAttachments() else diff --git a/DandersFrames/Core/Changelog.lua b/DandersFrames/Core/Changelog.lua index dd8341bc..84757694 100644 --- a/DandersFrames/Core/Changelog.lua +++ b/DandersFrames/Core/Changelog.lua @@ -1,5 +1,5 @@ local addonName, DF = ... -DF.BUILD_DATE = "2026-07-31T20:51:16Z" +DF.BUILD_DATE = "2026-08-01T09:39:26Z" DF.RELEASE_CHANNEL = "alpha" -- The changelog text itself lives in DandersFrames_Options/Changelog.lua: -- it is a ~180 KB string only the settings panel ever reads, and holding it diff --git a/DandersFrames/Core/Profile.lua b/DandersFrames/Core/Profile.lua index a2a068d0..4f94210b 100644 --- a/DandersFrames/Core/Profile.lua +++ b/DandersFrames/Core/Profile.lua @@ -869,10 +869,6 @@ end -- Get info about what's in the import data function DF:GetImportInfo(importData) if not importData then return nil end - -- Iterates DF.ExportCategories (companion-owned) unconditionally below. - if not DF.ExportCategories and DF.EnsureOptionsLoaded then - if not DF:EnsureOptionsLoaded() then return nil end - end local info = { version = self:GetImportVersion(importData), @@ -888,6 +884,14 @@ function DF:GetImportInfo(importData) -- Detect categories if not explicitly stored (legacy imports) if info.isFullExport then + -- ☠ The guard belongs HERE, not at the top of the function. The category + -- registry is companion-owned and only this branch reads it; a selective + -- payload carries its own category list. Guarding the whole function made + -- every import load the companion -- including the full-profile Wago path + -- that the comment below the caller explicitly says must not pay for it. + if not DF.ExportCategories and DF.EnsureOptionsLoaded then + if not DF:EnsureOptionsLoaded() then return nil end + end -- Full export contains all categories — derive the list from the -- category registry (single source of truth) rather than keeping a -- hand-maintained copy here that drifts when categories change. diff --git a/DandersFrames/GUI/Widgets.lua b/DandersFrames/GUI/Widgets.lua index f30c45aa..eeb623ff 100644 --- a/DandersFrames/GUI/Widgets.lua +++ b/DandersFrames/GUI/Widgets.lua @@ -2543,6 +2543,14 @@ end -- edge the anchor pins. local CURSOR_LIFT_X, CURSOR_LIFT_Y = 0, 8 +-- ☠ ShowTooltip lives here; ShowGameTooltip lives in the companion. The pair +-- straddles the addon boundary and must use the SAME lift -- a spell tooltip on +-- a settings row has to sit exactly where every other DF tooltip sits. +-- Published rather than duplicated so the two cannot drift apart, and because +-- the companion reading these as bare globals silently got nil (they are +-- optional SetOwner args, so it dropped the lift with no error). +P.CURSOR_LIFT_X, P.CURSOR_LIFT_Y = CURSOR_LIFT_X, CURSOR_LIFT_Y + function GUI:ShowTooltip(owner, opts) if not owner or not opts or not opts.title then return end if opts.anchor then diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 82599190..589b2208 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1,8 +1,11 @@ -- Part 4 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local L = DF.L local GUI = DF.GUI diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 09999d02..ac15ca4b 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1,8 +1,11 @@ -- Part 5 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local L = DF.L local GUI = DF.GUI diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 875dc9c1..1415b275 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1,8 +1,11 @@ -- Part 2 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local L = DF.L local GUI = DF.GUI diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index c9362e0b..c36514c4 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua @@ -1,8 +1,11 @@ -- Part 3 of the Aura Designer editor, split from Options.lua. -- Aliases of objects the first part created; they add no state. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local L = DF.L local GUI = DF.GUI diff --git a/DandersFrames_Options/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua index 4ce59398..2401a9bc 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Options.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Options.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/Changelog.lua b/DandersFrames_Options/Changelog.lua index 917ac6bc..9d07e130 100644 --- a/DandersFrames_Options/Changelog.lua +++ b/DandersFrames_Options/Changelog.lua @@ -1,5 +1,6 @@ -- GENERATED by generate_changelog.sh from CHANGELOG.md -- do not edit. --- ☠ Companion addon: bind the parent's table from the global, never `...`. +-- ☠ Companion addon: bind the parent's table from the global it publishes +-- at DandersFrames/Core.lua:9, never `...` (which yields THIS addon's table). local DF = DandersFrames DF.CHANGELOG_TEXT = [===[ # DandersFrames Changelog diff --git a/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua index 9fc96a4a..65c275a9 100755 --- a/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua +++ b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- Get module namespace diff --git a/DandersFrames_Options/ClickCasting/UI/Dialogs.lua b/DandersFrames_Options/ClickCasting/UI/Dialogs.lua index 525605f0..ba39ae35 100644 --- a/DandersFrames_Options/ClickCasting/UI/Dialogs.lua +++ b/DandersFrames_Options/ClickCasting/UI/Dialogs.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- Get module namespace diff --git a/DandersFrames_Options/ClickCasting/UI/Main.lua b/DandersFrames_Options/ClickCasting/UI/Main.lua index 2a417be3..2d0ff8d2 100755 --- a/DandersFrames_Options/ClickCasting/UI/Main.lua +++ b/DandersFrames_Options/ClickCasting/UI/Main.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- Get module namespace diff --git a/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua b/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua index 22f1b45e..e55fa9be 100644 --- a/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua +++ b/DandersFrames_Options/ClickCasting/UI/ProfilesPanel.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- Get module namespace diff --git a/DandersFrames_Options/Core/ExportCategories.lua b/DandersFrames_Options/Core/ExportCategories.lua index c7b73c34..0e266cb6 100644 --- a/DandersFrames_Options/Core/ExportCategories.lua +++ b/DandersFrames_Options/Core/ExportCategories.lua @@ -1,4 +1,9 @@ --- ☠ Companion addon: bind the parent table from the global, never `...`. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. -- Resident consumers (selective import/export in Core/Profile.lua) load this -- addon on demand before touching the tables below. local DF = DandersFrames diff --git a/DandersFrames_Options/DandersFrames_Options.toc b/DandersFrames_Options/DandersFrames_Options.toc index 69dc00df..65646ff1 100644 --- a/DandersFrames_Options/DandersFrames_Options.toc +++ b/DandersFrames_Options/DandersFrames_Options.toc @@ -15,19 +15,30 @@ # login; DandersFrames loads it the first time you open /df. # # Measured on the PTR build: while this stays unloaded DandersFrames uses -# 8,957 KB instead of 12,348 KB -- 3,391 KB less, 27.5% -- and ~55,000 lines +# 9,192 KB instead of 12,348 KB -- 3,156 KB less, about 25% -- and ~59,000 lines # are never parsed at any login. The memory saving lasts until the panel is # opened; the parse saving happens every login regardless. # # RequiredDeps plus the matching name prefix makes the addon list show this # as a collapsible child of DandersFrames rather than a separate entry. # -# ☠ ORDER IS LOAD-BEARING, exactly as in the main .toc. These files were split -# out of three monoliths and each group has a first file that creates the -# shared state every later part aliases: -# GUI/Sections -> Controls -> Panel (GUI.lua stays resident) -# AuraDesigner/UI/Options first (creates _uiState and _priv) -# GUI/Pages/Options first (heads the page chain) +# ☠ ORDER IS LOAD-BEARING in ONE place here, not three. Verified by resolving +# every file-scope alias in both addons rather than assumed -- two of the three +# constraints previously listed were not real, which is worse than none: a +# reader trusts the wrong ones and misses the one that bites. +# +# REAL, and cross-addon: GUI/Pages/AutoProfiles.lua aliases the RESIDENT +# AutoProfilesUI._priv (DandersFrames/Core/AutoProfiles.lua). The parent +# addon always loads first, so this holds -- but it is the actual dependency. +# REAL, within this addon: AuraDesigner/UI/Options.lua creates _uiState and +# _priv; Groups -> Indicators -> Cards -> Editor alias them in that order. +# +# NOT real: GUI/Sections -> Controls -> Panel. All three alias only +# GUI._state / GUI._priv from the RESIDENT GUI.lua and Widgets.lua, never +# each other. Reordering them breaks nothing. +# NOT real: GUI/Pages/Options "first". The parts are a runtime tail-call +# chain (_SetupGUIPagesPart2..5) -- each need only be loaded before +# SetupGUIPages is CALLED, so TOC order among them is free. # Selective import/export category tables. Only Profile.lua's selective # paths and the Modules page read these; both load this addon first. diff --git a/DandersFrames_Options/Debug/AtlasBrowser.lua b/DandersFrames_Options/Debug/AtlasBrowser.lua index 8549ec1b..6f12b2f7 100644 --- a/DandersFrames_Options/Debug/AtlasBrowser.lua +++ b/DandersFrames_Options/Debug/AtlasBrowser.lua @@ -1,9 +1,12 @@ -- Atlas Icon Browser for DandersFrames -- Opens with /df debug atlas or /dfatlas (dev builds only) --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local atlasFrame = nil diff --git a/DandersFrames_Options/Debug/AttachmentScan.lua b/DandersFrames_Options/Debug/AttachmentScan.lua index 604abfdd..8cf3de4f 100644 --- a/DandersFrames_Options/Debug/AttachmentScan.lua +++ b/DandersFrames_Options/Debug/AttachmentScan.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/Debug/AuraExplorer.lua b/DandersFrames_Options/Debug/AuraExplorer.lua index 783e97e1..36787303 100644 --- a/DandersFrames_Options/Debug/AuraExplorer.lua +++ b/DandersFrames_Options/Debug/AuraExplorer.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/Debug/MemoryTest.lua b/DandersFrames_Options/Debug/MemoryTest.lua index d82e4688..fc2a4588 100644 --- a/DandersFrames_Options/Debug/MemoryTest.lua +++ b/DandersFrames_Options/Debug/MemoryTest.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local addonName = "DandersFrames" local companionName = "DandersFrames_Options" diff --git a/DandersFrames_Options/Debug/ProfilerUI.lua b/DandersFrames_Options/Debug/ProfilerUI.lua index ce66c367..004eb677 100644 --- a/DandersFrames_Options/Debug/ProfilerUI.lua +++ b/DandersFrames_Options/Debug/ProfilerUI.lua @@ -5,7 +5,12 @@ -- pure presentation and loads with the settings companion. CreateUI already -- gated on DF:EnsureOptionsLoaded before the split, so every path that opens -- the window loads this file first. --- ☠ Companion addon: bind the parent table from the global, never `...`. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local Profiler = DF.Profiler local format = string.format diff --git a/DandersFrames_Options/Features/Search.lua b/DandersFrames_Options/Features/Search.lua index 771f85b4..44ad2075 100644 --- a/DandersFrames_Options/Features/Search.lua +++ b/DandersFrames_Options/Features/Search.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/FilterRegistry/UI/Options.lua b/DandersFrames_Options/FilterRegistry/UI/Options.lua index fd35a725..eda3e4f7 100644 --- a/DandersFrames_Options/FilterRegistry/UI/Options.lua +++ b/DandersFrames_Options/FilterRegistry/UI/Options.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua b/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua index 2f3566b5..7151266e 100644 --- a/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua +++ b/DandersFrames_Options/FilterRegistry/UI/SpellPicker.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/GUI/ColorPicker.lua b/DandersFrames_Options/GUI/ColorPicker.lua index da5f54ac..7b414e10 100644 --- a/DandersFrames_Options/GUI/ColorPicker.lua +++ b/DandersFrames_Options/GUI/ColorPicker.lua @@ -4,9 +4,12 @@ -- PLAYER_ENTERING_WORLD and exposes GUI:OpenColorPicker as the public API. -- Dev harness: /dfcolorhook (hook status/install/uninstall; dev builds only). --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local GUI = DF.GUI local L = DF.L diff --git a/DandersFrames_Options/GUI/Controls.lua b/DandersFrames_Options/GUI/Controls.lua index dbc1b3e9..194dc7e4 100644 --- a/DandersFrames_Options/GUI/Controls.lua +++ b/DandersFrames_Options/GUI/Controls.lua @@ -1,9 +1,12 @@ -- Part 4 of the GUI toolkit, split from the original GUI.lua. -- These re-declarations are aliases of the SAME objects the first part -- created; they add no state. See docs/reorg-tools/splits.manifest. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local GUI = DF.GUI local L = DF.L diff --git a/DandersFrames_Options/GUI/IconLib.lua b/DandersFrames_Options/GUI/IconLib.lua index c25c6161..b60f25b7 100644 --- a/DandersFrames_Options/GUI/IconLib.lua +++ b/DandersFrames_Options/GUI/IconLib.lua @@ -1,4 +1,9 @@ --- ☠ Companion addon: bind the parent table from the global, never `...`. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- Icon Library Preview diff --git a/DandersFrames_Options/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua index 1ee9db15..c48e8f21 100644 --- a/DandersFrames_Options/GUI/Pages/Auras.lua +++ b/DandersFrames_Options/GUI/Pages/Auras.lua @@ -1,8 +1,11 @@ -- Part 3 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) diff --git a/DandersFrames_Options/GUI/Pages/Frames.lua b/DandersFrames_Options/GUI/Pages/Frames.lua index 3dda6248..b3a60d34 100644 --- a/DandersFrames_Options/GUI/Pages/Frames.lua +++ b/DandersFrames_Options/GUI/Pages/Frames.lua @@ -1,8 +1,11 @@ -- Part 2 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart2(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) diff --git a/DandersFrames_Options/GUI/Pages/Indicators.lua b/DandersFrames_Options/GUI/Pages/Indicators.lua index 8a5eb38e..3660ef21 100644 --- a/DandersFrames_Options/GUI/Pages/Indicators.lua +++ b/DandersFrames_Options/GUI/Pages/Indicators.lua @@ -1,8 +1,11 @@ -- Part 4 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart4(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) diff --git a/DandersFrames_Options/GUI/Pages/Modules.lua b/DandersFrames_Options/GUI/Pages/Modules.lua index c7cf3d74..4a775b32 100644 --- a/DandersFrames_Options/GUI/Pages/Modules.lua +++ b/DandersFrames_Options/GUI/Pages/Modules.lua @@ -1,8 +1,11 @@ -- Part 5 of the settings pages, split from Options.lua. -- The parts run as a chain so the pages build in their original order. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local format = string.format function DF._SetupGUIPagesPart5(GUI, CreateCategory, CreateSubTab, BuildPage, L, AddColorsPageLink, CreateCopyButton, pagePinnedFrames, pageBuffs, pageIcons) diff --git a/DandersFrames_Options/GUI/Pages/NicknamesPage.lua b/DandersFrames_Options/GUI/Pages/NicknamesPage.lua index 7670bfa0..e1f7e927 100644 --- a/DandersFrames_Options/GUI/Pages/NicknamesPage.lua +++ b/DandersFrames_Options/GUI/Pages/NicknamesPage.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua b/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua index 52b30f48..d5d488e9 100644 --- a/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua +++ b/DandersFrames_Options/GUI/Pages/NicknamesPicker.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- ============================================================ diff --git a/DandersFrames_Options/GUI/Pages/Options.lua b/DandersFrames_Options/GUI/Pages/Options.lua index d9e2fa51..05f60b73 100644 --- a/DandersFrames_Options/GUI/Pages/Options.lua +++ b/DandersFrames_Options/GUI/Pages/Options.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local format = string.format diff --git a/DandersFrames_Options/GUI/Panel.lua b/DandersFrames_Options/GUI/Panel.lua index ea092451..8d2c137f 100644 --- a/DandersFrames_Options/GUI/Panel.lua +++ b/DandersFrames_Options/GUI/Panel.lua @@ -1,9 +1,12 @@ -- Part 5 of the GUI toolkit, split from the original GUI.lua. -- These re-declarations are aliases of the SAME objects the first part -- created; they add no state. See docs/reorg-tools/splits.manifest. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local GUI = DF.GUI local L = DF.L diff --git a/DandersFrames_Options/GUI/Sections.lua b/DandersFrames_Options/GUI/Sections.lua index b595e7a5..3efe884e 100644 --- a/DandersFrames_Options/GUI/Sections.lua +++ b/DandersFrames_Options/GUI/Sections.lua @@ -1,9 +1,12 @@ -- Part 2 of the GUI toolkit, split from the original GUI.lua. -- These re-declarations are aliases of the SAME objects the first part -- created; they add no state. See docs/reorg-tools/splits.manifest. --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local GUI = DF.GUI local L = DF.L @@ -19,6 +22,8 @@ local CreateElementBackdrop = GUI._priv.CreateElementBackdrop -- with this addon unloaded. Aliases of those objects, not copies. local INFO_BANNER_TONES = P.INFO_BANNER_TONES local AddTooltipLines = P.AddTooltipLines +-- ShowGameTooltip below shares its cursor lift with the resident ShowTooltip. +local CURSOR_LIFT_X, CURSOR_LIFT_Y = P.CURSOR_LIFT_X, P.CURSOR_LIFT_Y function GUI:CreateSettingsGroup(parent, width, opts) diff --git a/DandersFrames_Options/TestMode/TestFramePool.lua b/DandersFrames_Options/TestMode/TestFramePool.lua index 39ae9a1b..3025070f 100755 --- a/DandersFrames_Options/TestMode/TestFramePool.lua +++ b/DandersFrames_Options/TestMode/TestFramePool.lua @@ -4,9 +4,12 @@ -- These frames are completely independent of live header children -- ============================================================ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames -- Test frame storage diff --git a/DandersFrames_Options/TestMode/TestMode.lua b/DandersFrames_Options/TestMode/TestMode.lua index 0b0a75c3..3c2b8f98 100644 --- a/DandersFrames_Options/TestMode/TestMode.lua +++ b/DandersFrames_Options/TestMode/TestMode.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local L = DF.L diff --git a/DandersFrames_Options/TextDesigner/UI/Options.lua b/DandersFrames_Options/TextDesigner/UI/Options.lua index 2ca2362e..2539e1a5 100644 --- a/DandersFrames_Options/TextDesigner/UI/Options.lua +++ b/DandersFrames_Options/TextDesigner/UI/Options.lua @@ -1,6 +1,9 @@ --- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, --- so every DF.* read here would be nil. DandersFrames publishes its own table --- as a global via ## AllowAddOnTableAccess -- take it from there. +-- ☠ Companion addon: `...` yields THIS addon's private table, not the +-- parent's, so every DF.* read here would be nil. Take the parent's table +-- from the global it publishes at DandersFrames/Core.lua:9 (`_G[addonName] +-- = DF`). NOT from ## AllowAddOnTableAccess -- that directive governs +-- access to an addon's PRIVATE table and has nothing to do with the global +-- name; deleting Core.lua:9 as "redundant" would nil DF in every file here. local DF = DandersFrames local L = DF.L diff --git a/README.md b/README.md index 992f385f..0f57d7d8 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,22 @@ This addon bundles the following third-party libraries, each under their own res ## Development setup -This repo is a **container**, not an addon folder. Its root holds the addon -folder rather than being it, the way ElvUI and Grid2 are laid out: +This repo is a **container**, not an addon folder. Its root holds two addon +folders rather than being one: ``` / - DandersFrames/ the addon - README.md CHANGELOG.md .pkgmeta .github/ Tools/ + DandersFrames/ the addon (always loaded) + DandersFrames_Options/ settings panel, designers, debug tools + (## LoadOnDemand — loads when /df is opened) + README.md CHANGELOG.md .pkgmeta .github/ Tools/ docs/ ``` WoW only loads addons from `Interface/AddOns//.toc`, so the repo -cannot live inside `AddOns/` any more. Clone it anywhere and link the addon -folder in with a **directory junction** — a junction (`/J`), not a symlink -(`/D`), so no admin rights or Developer Mode are needed. +cannot live inside `AddOns/` any more, and a load-on-demand companion has to be +a **sibling** top-level folder rather than a subfolder. Clone the repo anywhere +and link **both** folders in with **directory junctions** — junctions (`/J`), +not symlinks (`/D`), so no admin rights or Developer Mode are needed. ### One-time, per game install @@ -73,8 +76,32 @@ mklink /J "DandersFrames" "C:\path\to\repo\DandersFrames" mklink /J "DandersFrames_Options" "C:\path\to\repo\DandersFrames_Options" ``` -Delete any real `AddOns/DandersFrames` folder first — a junction cannot -replace an existing directory. Repeat per install (`_retail_`, `_ptr_`). +A junction cannot replace an existing directory, so anything already at those +names has to go first. Repeat per game install — `_retail_`, `_ptr_`, `_beta_` +— so six junctions if you run all three. + +> ### ☠ Removing an existing `AddOns/DandersFrames` — read this +> +> If you already develop DandersFrames, that path is **probably a junction, not +> a folder**, and `rm -rf` in Git Bash *follows it* and deletes your working +> tree on the other side. Losing an unpushed branch to a cleanup step is a +> miserable way to start. +> +> Check what it is, then remove it as a link: +> +> ``` +> cmd //c dir /AL "DandersFrames" # lists it if it is a junction +> cmd //c rmdir "DandersFrames" # removes the LINK, never the target +> ``` +> +> `rmdir` refuses to touch a non-empty real directory, so it fails safe either +> way. Only reach for a recursive delete once you have confirmed it is a real +> folder you actually want gone. + +**If `/df` does not open after this**, the usual cause is only the first +junction existing — the panel lives in `DandersFrames_Options`, so the addon +loads fine and the settings command does nothing useful. Note also that a new +addon folder is only picked up on a **full client restart**, not `/reload`. After that, edit in the repo and `/reload` in game; the junction means there is no copy or sync step. `git status`, branches and PRs are unchanged — one @@ -101,15 +128,34 @@ packager running locally. **Nothing may depend on the companion to work.** Live behaviour that happened to live in a settings file has to move back to the main addon, not be guarded away — the auto-profile engine, the click-casting bootstrap and the aura -migrations are all resident for that reason. `docs/reorg-tools/lod_login_check.py` -catches the common form of this. +migrations are all resident for that reason. + +The rule in practice, when adding anything: + +| Kind | Do | +|---|---| +| Deliberate user action (`/df test`, unlock, profiler, selective import/export) | **Load** the companion — `DF:EnsureOptionsLoaded()` | +| Background event (a talent-change UI refresh) | **Guard** and no-op — a refresh with no UI is correctly nothing | +| Live behaviour | **Keep it resident** | + +☠ A companion file's `...` is the *companion's* addon table, not ours. Every +file there binds `local DF = DandersFrames` — the global published at +`DandersFrames/Core.lua:9`, **not** by `## AllowAddOnTableAccess`, which +governs private-table access and is unrelated to the global name. ### Verification tooling -`docs/reorg-tools/` holds the checkers used during the restructure — TOC -completeness, load order, split-plumbing aliases, and the load-on-demand -boundary. They default to `/DandersFrames`, so they run with no -arguments from the repo root. `docs/` is gitignored; the tools are local. +A set of checkers was written during the restructure — TOC completeness, load +order, split-plumbing aliases, the load-on-demand boundary, and login-time work +stranded in the companion. Two are worth running after anything that moves code +between files: the alias checker (this caught two in-game crashes) and the +boundary checker (which watches the `DF`, `GUI` and `CC` namespaces separately, +because one that watches a single namespace is blind to the others). + +⚠ These live under `docs/reorg-tools/`, which is **gitignored and not part of +this repo** — ask Krathe for a copy. Most of `docs/` is local; the one tracked +exception is `docs/gui-conventions.md`. Some in-code comments cite +`docs/reorg-tools/splits.manifest` for the same reason. ## License diff --git a/generate_changelog.sh b/generate_changelog.sh index a3d99a95..ef176cb6 100755 --- a/generate_changelog.sh +++ b/generate_changelog.sh @@ -200,7 +200,8 @@ LUAEOF # its OWN addon table, so it must take the parent's from the global. cat > "$OUTPUT_TEXT_FILE" << LUAEOF -- GENERATED by generate_changelog.sh from CHANGELOG.md -- do not edit. --- ☠ Companion addon: bind the parent's table from the global, never \`...\`. +-- ☠ Companion addon: bind the parent's table from the global it publishes +-- at DandersFrames/Core.lua:9, never \`...\` (which yields THIS addon's table). local DF = DandersFrames DF.CHANGELOG_TEXT = [===[ ${CHANGELOG_CONTENT} From 78719df361ec0dd03a604f97939d7c0dd0343696 Mon Sep 17 00:00:00 2001 From: Krathe Date: Sat, 1 Aug 2026 10:53:50 +0100 Subject: [PATCH 33/34] gui-conventions: the menu registry straddles the addon boundary The doc listed GUI:RegisterMenu and GUI:CloseAllMenus as a pair. The split put the registry table and RegisterMenu resident and CloseAllMenus in the companion, so resident code can register a menu but has no way to close one. Nothing is broken today -- both CloseAllMenus callers are companion-side, so the reader and its callers load together -- but resident CreateDropdown does register menus, and its one resident caller (the mover panel's anchor dropdown) exists with the panel unloaded. Someone reaching for CloseAllMenus from resident code would find it nil, and because it is a bulk tidy-up call a nil-guard fails SILENTLY by leaving a menu floating: the exact symptom the registry exists to prevent. Documented as a boundary rather than a pair, with the recommendation to move the four lines resident if it is ever needed there -- guarding the call would document the split instead of removing it. Also distinguishes it from CloseOpenDropdown, which is resident, published on _priv, available both sides, and is what most call sites actually want. Caught by Danders' review of #222. --- docs/gui-conventions.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/gui-conventions.md b/docs/gui-conventions.md index d793a579..4640c600 100644 --- a/docs/gui-conventions.md +++ b/docs/gui-conventions.md @@ -182,7 +182,7 @@ one surface behave unlike the rest of the addon. | Need | Helper | ☠ What breaks if you skip it | |------|--------|------------------------------| -| A dropdown-style popup menu | `GUI:RegisterMenu(frame)`, `GUI:CloseAllMenus()` | **Register every menu frame at creation.** A menu that isn't registered stays open through a page change, mode switch or window close, and floats over whatever comes next | +| A dropdown-style popup menu | `GUI:RegisterMenu(frame)` — resident; `GUI:CloseAllMenus()` — **companion-only, see below** | **Register every menu frame at creation.** A menu that isn't registered stays open through a page change, mode switch or window close, and floats over whatever comes next | | A widget that changes height | `GUI:RelayoutHost(widget, slotHeight)` | Updates the group's stored slot, re-lays the group, **then bubbles to the page** so sibling groups re-anchor. Without the bubble a grown group's backdrop overshoots the next group's anchor and paints an empty rectangle above it | | A border on a bespoke frame | `GUI:ApplyPixelBorder(frame, {r,g,b,a}, weight)`, `GUI:HidePixelBorder(frame)`, `GUI:RefreshPixelBorders()` | Our two-device-pixel border. A `SetBackdrop` edge instead gives you the thinning / vanishing hairline the whole system exists to avoid — see "The pixel grid" | | Whole-pixel geometry | `GUI.SnapLen`, `GUI.SnapLenUp` | Right/top edges land mid-pixel and clip whatever they contain | @@ -192,6 +192,37 @@ one surface behave unlike the rest of the addon. | Colour-picker interop | `GUI:InstallColorPickerHook()` / `Uninstall…` / `IsColorPickerHookInstalled()` / `MarkColorPickerCall()` | Keeps Blizzard's picker from stomping our own; mark your call or the hook can't tell whose it is | | Section collapse state | `GUI:GetCollapsedGroups()` | Persisted collapse state — read it, don't track your own | +### ⚠ The menu registry straddles the addon boundary + +The three pieces of the menu system do not live together, which the table above +cannot show: + +| Piece | Where | Loads | +|---|---|---| +| `GUI._menus` (the registry table) | `DandersFrames/GUI/Widgets.lua` | always | +| `GUI:RegisterMenu(frame)` | `DandersFrames/GUI/Widgets.lua` | always | +| `GUI:CloseAllMenus()` | `DandersFrames_Options/GUI/SettingsWidgets.lua` | with the settings panel | + +So **resident code can register a menu but cannot close one.** Resident +`GUI:CreateDropdown` registers its menu frame, and it has one resident caller — +the mover panel's anchor dropdown (`Frames/Position.lua`), which exists with +the settings panel unloaded. + +Nothing is broken today: both `CloseAllMenus` callers are companion-side +(`AuraDesigner/UI/Cards.lua`), so the reader and its callers load together. But +if you are writing resident code and reach for `CloseAllMenus`, **it will be +nil until someone has opened `/df`** — and because it is a bulk "tidy up" +call, a nil-guard that skips it fails silently by leaving a menu floating, +which is exactly the symptom the registry exists to prevent. + +If you need it resident, move the function rather than guarding the call: it is +four lines and it belongs with the registry it iterates. Guarding would +document the split instead of removing it. + +☠ Not the same thing as `CloseOpenDropdown` (resident, published on +`GUI._priv`), which closes the *single currently-open* dropdown. That one is +available on both sides and is what most call sites actually want. + ## Popups — never Blizzard's There are **zero** `StaticPopupDialogs` / `StaticPopup_Show` calls left in the addon. Use the DF From e0586adf6549f2674f18a024b3323df933b045fb Mon Sep 17 00:00:00 2001 From: Krathe Date: Sat, 1 Aug 2026 11:29:11 +0100 Subject: [PATCH 34/34] Move GUI:CloseAllMenus back resident, with its registry Four lines, and having them in the companion split a three-part unit across an addon boundary for no benefit: the registry table and RegisterMenu were resident while the only thing that reads the registry was not. Resident code could add a menu but not close one, and resident CreateDropdown does register (the mover panel's anchor dropdown is a resident caller). Nothing was broken -- both callers are companion-side, so reader and callers loaded together -- but the failure mode if anyone had reached for it from resident code was the bad kind: a bulk 'dismiss whatever is open' call that is nil half the time fails SILENTLY, leaving a menu floating, which is the exact symptom the registry exists to prevent. Taking the fix rather than documenting the wart, which is also the lesson from the two ORDER-IS-LOAD-BEARING claims this review found to be false: a comment describing a split is worse than not having the split. gui-conventions.md updated to match -- it now says all three pieces are resident and should stay together, keeps the reasoning as a note on why, and still distinguishes CloseAllMenus from CloseOpenDropdown (resident, on _priv, the single-open-dropdown case most call sites actually want). --- DandersFrames/GUI/Widgets.lua | 14 +++++ DandersFrames_Options/GUI/SettingsWidgets.lua | 6 --- docs/gui-conventions.md | 54 +++++++++---------- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/DandersFrames/GUI/Widgets.lua b/DandersFrames/GUI/Widgets.lua index eeb623ff..a487067d 100644 --- a/DandersFrames/GUI/Widgets.lua +++ b/DandersFrames/GUI/Widgets.lua @@ -1822,6 +1822,20 @@ end GUI._menus = GUI._menus or {} function GUI:RegisterMenu(frame) self._menus[frame] = true end +-- ☠ Lives with the registry it iterates, deliberately. This was in the +-- companion for a while, which left resident code able to REGISTER a menu but +-- not close one -- and resident CreateDropdown below does register (the mover +-- panel's anchor dropdown is a resident caller). Nothing broke, because both +-- callers happened to be companion-side, but a bulk "dismiss whatever is open" +-- call that is nil half the time fails SILENTLY: the menu just stays floating, +-- which is the exact symptom this registry exists to prevent. Four lines is +-- not worth an addon boundary. +function GUI:CloseAllMenus() + for f in pairs(self._menus) do + if f:IsShown() then f:Hide() end + end +end + function GUI:CreateDropdown(parent, label, options, dbTable, dbKey, callback, customGet, customSet, opts) opts = opts or {} local accentColor = opts.accent diff --git a/DandersFrames_Options/GUI/SettingsWidgets.lua b/DandersFrames_Options/GUI/SettingsWidgets.lua index 998e7009..c02e044a 100644 --- a/DandersFrames_Options/GUI/SettingsWidgets.lua +++ b/DandersFrames_Options/GUI/SettingsWidgets.lua @@ -1922,12 +1922,6 @@ function GUI:CreateColorPicker(parent, label, dbTable, dbKey, hasAlpha, callback return container end -function GUI:CloseAllMenus() - for f in pairs(self._menus) do - if f:IsShown() then f:Hide() end - end -end - function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) local options = { NONE = L["None"], diff --git a/docs/gui-conventions.md b/docs/gui-conventions.md index 4640c600..c87b1942 100644 --- a/docs/gui-conventions.md +++ b/docs/gui-conventions.md @@ -182,7 +182,7 @@ one surface behave unlike the rest of the addon. | Need | Helper | ☠ What breaks if you skip it | |------|--------|------------------------------| -| A dropdown-style popup menu | `GUI:RegisterMenu(frame)` — resident; `GUI:CloseAllMenus()` — **companion-only, see below** | **Register every menu frame at creation.** A menu that isn't registered stays open through a page change, mode switch or window close, and floats over whatever comes next | +| A dropdown-style popup menu | `GUI:RegisterMenu(frame)`, `GUI:CloseAllMenus()` | **Register every menu frame at creation.** A menu that isn't registered stays open through a page change, mode switch or window close, and floats over whatever comes next | | A widget that changes height | `GUI:RelayoutHost(widget, slotHeight)` | Updates the group's stored slot, re-lays the group, **then bubbles to the page** so sibling groups re-anchor. Without the bubble a grown group's backdrop overshoots the next group's anchor and paints an empty rectangle above it | | A border on a bespoke frame | `GUI:ApplyPixelBorder(frame, {r,g,b,a}, weight)`, `GUI:HidePixelBorder(frame)`, `GUI:RefreshPixelBorders()` | Our two-device-pixel border. A `SetBackdrop` edge instead gives you the thinning / vanishing hairline the whole system exists to avoid — see "The pixel grid" | | Whole-pixel geometry | `GUI.SnapLen`, `GUI.SnapLenUp` | Right/top edges land mid-pixel and clip whatever they contain | @@ -192,36 +192,32 @@ one surface behave unlike the rest of the addon. | Colour-picker interop | `GUI:InstallColorPickerHook()` / `Uninstall…` / `IsColorPickerHookInstalled()` / `MarkColorPickerCall()` | Keeps Blizzard's picker from stomping our own; mark your call or the hook can't tell whose it is | | Section collapse state | `GUI:GetCollapsedGroups()` | Persisted collapse state — read it, don't track your own | -### ⚠ The menu registry straddles the addon boundary +### The menu registry — all three pieces are resident -The three pieces of the menu system do not live together, which the table above -cannot show: +The registry table, its writer and its reader live together in +`DandersFrames/GUI/Widgets.lua`, and they should stay that way: -| Piece | Where | Loads | -|---|---|---| -| `GUI._menus` (the registry table) | `DandersFrames/GUI/Widgets.lua` | always | -| `GUI:RegisterMenu(frame)` | `DandersFrames/GUI/Widgets.lua` | always | -| `GUI:CloseAllMenus()` | `DandersFrames_Options/GUI/SettingsWidgets.lua` | with the settings panel | - -So **resident code can register a menu but cannot close one.** Resident -`GUI:CreateDropdown` registers its menu frame, and it has one resident caller — -the mover panel's anchor dropdown (`Frames/Position.lua`), which exists with -the settings panel unloaded. - -Nothing is broken today: both `CloseAllMenus` callers are companion-side -(`AuraDesigner/UI/Cards.lua`), so the reader and its callers load together. But -if you are writing resident code and reach for `CloseAllMenus`, **it will be -nil until someone has opened `/df`** — and because it is a bulk "tidy up" -call, a nil-guard that skips it fails silently by leaving a menu floating, -which is exactly the symptom the registry exists to prevent. - -If you need it resident, move the function rather than guarding the call: it is -four lines and it belongs with the registry it iterates. Guarding would -document the split instead of removing it. - -☠ Not the same thing as `CloseOpenDropdown` (resident, published on -`GUI._priv`), which closes the *single currently-open* dropdown. That one is -available on both sides and is what most call sites actually want. +| Piece | Where | +|---|---| +| `GUI._menus` (the registry table) | resident | +| `GUI:RegisterMenu(frame)` | resident | +| `GUI:CloseAllMenus()` | resident | + +`CloseAllMenus` briefly lived in the companion during the load-on-demand split. +That left resident code able to *register* a menu but not close one — and +resident `GUI:CreateDropdown` does register, with a resident caller (the mover +panel's anchor dropdown). Nothing broke, because both `CloseAllMenus` callers +happened to be companion-side, but the failure mode was the bad kind: a bulk +"dismiss whatever is open" call that is nil half the time fails **silently**, +leaving a menu floating — the exact symptom the registry exists to prevent. + +☠ Four lines are not worth an addon boundary. If you ever find yourself +nil-guarding a call like this, move the function instead — a guard documents +the split rather than removing it. + +Not the same thing as `CloseOpenDropdown` (resident, published on `GUI._priv`), +which closes the *single currently-open* dropdown. That is what most call sites +actually want. ## Popups — never Blizzard's